Add role-based visibility and management features for products
- Introduced a role-based visibility system for products, ensuring that only users with approved roles can view specific products. - Added new database tables for roles, user roles, and product role visibility to manage access control. - Implemented utility functions for role management, including fetching approved roles, checking product visibility, and assigning roles to users and products. - Updated API endpoints to filter products based on user roles, enhancing security and user experience. - Prepared the database schema for future role request and approval workflows in upcoming phases.
This commit is contained in:
261
server/utils/roles.ts
Normal file
261
server/utils/roles.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Role Management Utilities
|
||||
*
|
||||
* Helper functions for managing user roles and product visibility.
|
||||
* MVP: These functions work with manually assigned roles (status='approved')
|
||||
* Phase 2/3: Will be extended to handle role request/approval workflow
|
||||
*/
|
||||
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import type { SQL } from 'drizzle-orm'
|
||||
import { roles, userRoles, productRoleVisibility } from '../database/schema'
|
||||
|
||||
/**
|
||||
* Get all approved roles for a user
|
||||
* MVP: Returns roles with status='approved' (manually assigned via DB)
|
||||
* Phase 2/3: Used to check which products a user can see
|
||||
*
|
||||
* @param userId - The user's UUID
|
||||
* @returns Array of role objects with full details
|
||||
*/
|
||||
export async function getUserApprovedRoles(userId: string) {
|
||||
const db = useDatabase()
|
||||
|
||||
const approvedRoles = await db.query.userRoles.findMany({
|
||||
where: and(eq(userRoles.userId, userId), eq(userRoles.status, 'approved')),
|
||||
with: {
|
||||
role: true,
|
||||
},
|
||||
})
|
||||
|
||||
return approvedRoles.map((ur) => ur.role)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get approved role codes for a user (lightweight version)
|
||||
* Returns just the role codes ['private', 'educator'] instead of full objects
|
||||
*
|
||||
* @param userId - The user's UUID
|
||||
* @returns Array of role codes
|
||||
*/
|
||||
export async function getUserApprovedRoleCodes(userId: string): Promise<string[]> {
|
||||
const approvedRoles = await getUserApprovedRoles(userId)
|
||||
return approvedRoles.map((role) => role.code)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific product is visible to a user based on their roles
|
||||
*
|
||||
* Logic:
|
||||
* - If product has NO role assignments → NOT visible (opt-in visibility)
|
||||
* - If product HAS role assignments → visible only if user has at least one matching role
|
||||
*
|
||||
* @param productId - The product's UUID
|
||||
* @param userId - The user's UUID
|
||||
* @returns true if user can see the product, false otherwise
|
||||
*/
|
||||
export async function isProductVisibleForUser(
|
||||
productId: string,
|
||||
userId: string
|
||||
): Promise<boolean> {
|
||||
const db = useDatabase()
|
||||
|
||||
// Get user's approved role codes
|
||||
const userRoleCodes = await getUserApprovedRoleCodes(userId)
|
||||
|
||||
// Get product's role visibility assignments
|
||||
const productRoles = await db.query.productRoleVisibility.findMany({
|
||||
where: eq(productRoleVisibility.productId, productId),
|
||||
with: {
|
||||
role: true,
|
||||
},
|
||||
})
|
||||
|
||||
// No role assignments → product is NOT visible (opt-in)
|
||||
if (productRoles.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if user has at least one matching role
|
||||
const productRoleCodes = productRoles.map((pr) => pr.role.code)
|
||||
return productRoleCodes.some((code) => userRoleCodes.includes(code))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all products visible to a user based on their roles
|
||||
* Returns a WHERE clause that can be used with Drizzle queries
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* const visibleProductIds = await getVisibleProductIdsForUser(userId)
|
||||
* const products = await db.query.products.findMany({
|
||||
* where: inArray(products.id, visibleProductIds)
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @param userId - The user's UUID
|
||||
* @returns Array of product UUIDs that the user can see
|
||||
*/
|
||||
export async function getVisibleProductIdsForUser(userId: string): Promise<string[]> {
|
||||
const db = useDatabase()
|
||||
|
||||
// Get user's approved role IDs
|
||||
const userRoleRecords = await db.query.userRoles.findMany({
|
||||
where: and(eq(userRoles.userId, userId), eq(userRoles.status, 'approved')),
|
||||
})
|
||||
|
||||
const userRoleIds = userRoleRecords.map((ur) => ur.roleId)
|
||||
|
||||
// If user has no approved roles, they can't see any products
|
||||
if (userRoleIds.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Get all products assigned to these roles
|
||||
const visibleProducts = await db.query.productRoleVisibility.findMany({
|
||||
where: inArray(productRoleVisibility.roleId, userRoleIds),
|
||||
})
|
||||
|
||||
// Return unique product IDs
|
||||
return [...new Set(visibleProducts.map((pv) => pv.productId))]
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a role to a user (MVP: manual assignment, always approved)
|
||||
* Phase 2/3: This will be replaced by role request workflow
|
||||
*
|
||||
* @param userId - The user's UUID
|
||||
* @param roleCode - Role code ('private', 'educator', 'company')
|
||||
* @param options - Optional metadata (organization name, admin notes)
|
||||
*/
|
||||
export async function assignRoleToUser(
|
||||
userId: string,
|
||||
roleCode: 'private' | 'educator' | 'company',
|
||||
options?: {
|
||||
organizationName?: string
|
||||
adminNotes?: string
|
||||
}
|
||||
) {
|
||||
const db = useDatabase()
|
||||
|
||||
// Find role by code
|
||||
const role = await db.query.roles.findFirst({
|
||||
where: eq(roles.code, roleCode),
|
||||
})
|
||||
|
||||
if (!role) {
|
||||
throw new Error(`Role '${roleCode}' not found`)
|
||||
}
|
||||
|
||||
// Check if user already has this role
|
||||
const existing = await db.query.userRoles.findFirst({
|
||||
where: and(eq(userRoles.userId, userId), eq(userRoles.roleId, role.id)),
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
throw new Error(`User already has role '${roleCode}'`)
|
||||
}
|
||||
|
||||
// Create user role assignment (approved in MVP)
|
||||
const [userRole] = await db
|
||||
.insert(userRoles)
|
||||
.values({
|
||||
userId,
|
||||
roleId: role.id,
|
||||
status: 'approved', // MVP: Always approved
|
||||
organizationName: options?.organizationName,
|
||||
adminNotes: options?.adminNotes,
|
||||
statusHistory: [
|
||||
{
|
||||
status: 'approved',
|
||||
organizationName: options?.organizationName || null,
|
||||
adminNotes: options?.adminNotes || null,
|
||||
changedAt: new Date().toISOString(),
|
||||
changedBy: null, // MVP: Manual assignment (no admin tracking)
|
||||
},
|
||||
],
|
||||
})
|
||||
.returning()
|
||||
|
||||
return userRole
|
||||
}
|
||||
|
||||
/**
|
||||
* Get role by code (helper function)
|
||||
*
|
||||
* @param roleCode - Role code ('private', 'educator', 'company')
|
||||
* @returns Role object or null if not found
|
||||
*/
|
||||
export async function getRoleByCode(roleCode: 'private' | 'educator' | 'company') {
|
||||
const db = useDatabase()
|
||||
|
||||
return await db.query.roles.findFirst({
|
||||
where: eq(roles.code, roleCode),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign roles to a product based on category (for ERP import)
|
||||
*
|
||||
* Category Mapping:
|
||||
* - 'makerspace-annual-pass' → ['private', 'educator']
|
||||
* - 'annual-pass' → ['private']
|
||||
* - 'educator-annual-pass' → ['educator']
|
||||
* - 'company-annual-pass' → ['company']
|
||||
*
|
||||
* @param productId - The product's UUID
|
||||
* @param category - Product category from NAV ERP
|
||||
*/
|
||||
export async function assignRolesToProductByCategory(
|
||||
productId: string,
|
||||
category: string
|
||||
) {
|
||||
const db = useDatabase()
|
||||
|
||||
// Category to role mapping
|
||||
const categoryRoleMapping: Record<string, Array<'private' | 'educator' | 'company'>> = {
|
||||
'makerspace-annual-pass': ['private', 'educator'],
|
||||
'annual-pass': ['private'],
|
||||
'educator-annual-pass': ['educator'],
|
||||
'company-annual-pass': ['company'],
|
||||
}
|
||||
|
||||
const roleCodes = categoryRoleMapping[category] || []
|
||||
|
||||
if (roleCodes.length === 0) {
|
||||
console.warn(`No role mapping found for category '${category}'`)
|
||||
return []
|
||||
}
|
||||
|
||||
// Get role IDs
|
||||
const roleRecords = await db.query.roles.findMany({
|
||||
where: inArray(roles.code, roleCodes),
|
||||
})
|
||||
|
||||
const assignments = []
|
||||
|
||||
// Create product-role assignments
|
||||
for (const role of roleRecords) {
|
||||
// Check if assignment already exists
|
||||
const existing = await db.query.productRoleVisibility.findFirst({
|
||||
where: and(
|
||||
eq(productRoleVisibility.productId, productId),
|
||||
eq(productRoleVisibility.roleId, role.id)
|
||||
),
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
const [assignment] = await db
|
||||
.insert(productRoleVisibility)
|
||||
.values({
|
||||
productId,
|
||||
roleId: role.id,
|
||||
})
|
||||
.returning()
|
||||
|
||||
assignments.push(assignment)
|
||||
}
|
||||
}
|
||||
|
||||
return assignments
|
||||
}
|
||||
Reference in New Issue
Block a user