Browse Source
- 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.main
10 changed files with 1865 additions and 26 deletions
@ -0,0 +1,43 @@ |
|||
CREATE TYPE "public"."role_code" AS ENUM('private', 'educator', 'company');--> statement-breakpoint |
|||
CREATE TYPE "public"."role_request_status" AS ENUM('pending', 'approved', 'rejected');--> statement-breakpoint |
|||
CREATE TABLE "product_role_visibility" ( |
|||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, |
|||
"product_id" uuid NOT NULL, |
|||
"role_id" uuid NOT NULL, |
|||
"created_at" timestamp DEFAULT now() NOT NULL |
|||
); |
|||
--> statement-breakpoint |
|||
CREATE TABLE "roles" ( |
|||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, |
|||
"code" "role_code" NOT NULL, |
|||
"display_name" text NOT NULL, |
|||
"description" text NOT NULL, |
|||
"requires_approval" boolean DEFAULT false NOT NULL, |
|||
"sort_order" integer DEFAULT 0 NOT NULL, |
|||
"active" boolean DEFAULT true NOT NULL, |
|||
"created_at" timestamp DEFAULT now() NOT NULL, |
|||
"updated_at" timestamp DEFAULT now() NOT NULL, |
|||
CONSTRAINT "roles_code_unique" UNIQUE("code") |
|||
); |
|||
--> statement-breakpoint |
|||
CREATE TABLE "user_roles" ( |
|||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, |
|||
"user_id" uuid NOT NULL, |
|||
"role_id" uuid NOT NULL, |
|||
"status" "role_request_status" DEFAULT 'pending' NOT NULL, |
|||
"organization_name" text, |
|||
"admin_notes" text, |
|||
"status_history" jsonb DEFAULT '[]' NOT NULL, |
|||
"created_at" timestamp DEFAULT now() NOT NULL, |
|||
"updated_at" timestamp DEFAULT now() NOT NULL |
|||
); |
|||
--> statement-breakpoint |
|||
ALTER TABLE "product_role_visibility" ADD CONSTRAINT "product_role_visibility_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint |
|||
ALTER TABLE "product_role_visibility" ADD CONSTRAINT "product_role_visibility_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint |
|||
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint |
|||
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint |
|||
CREATE INDEX "product_role_visibility_product_id_role_id_unique" ON "product_role_visibility" USING btree ("product_id","role_id");--> statement-breakpoint |
|||
CREATE INDEX "product_role_visibility_product_id_idx" ON "product_role_visibility" USING btree ("product_id");--> statement-breakpoint |
|||
CREATE INDEX "user_roles_user_id_role_id_unique" ON "user_roles" USING btree ("user_id","role_id");--> statement-breakpoint |
|||
CREATE INDEX "user_roles_user_id_idx" ON "user_roles" USING btree ("user_id");--> statement-breakpoint |
|||
CREATE INDEX "user_roles_status_idx" ON "user_roles" USING btree ("status"); |
|||
File diff suppressed because it is too large
@ -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 |
|||
} |
|||
Loading…
Reference in new issue