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