From 68ab42f2f4c01ea373a068cf7340f20a9bfe5291 Mon Sep 17 00:00:00 2001 From: Bastian Masanek Date: Mon, 3 Nov 2025 10:45:11 +0100 Subject: [PATCH] Refactor roles management to use role code as primary key - Updated the roles table schema to use role code as the primary key, enhancing readability in junction tables. - Modified related tables (user_roles, product_role_visibility) to reference role code instead of role ID. - Adjusted seeding logic and utility functions to accommodate the new primary key structure, ensuring consistent role management across the application. - Added a migration script to facilitate the database schema changes. --- .../0002_refactor_roles_primary_key.sql | 58 +++++++++++++++++++ server/database/migrations/meta/_journal.json | 7 +++ server/database/schema.ts | 26 +++++---- server/database/seed.ts | 6 +- server/utils/roles.ts | 34 ++++------- 5 files changed, 93 insertions(+), 38 deletions(-) create mode 100644 server/database/migrations/0002_refactor_roles_primary_key.sql diff --git a/server/database/migrations/0002_refactor_roles_primary_key.sql b/server/database/migrations/0002_refactor_roles_primary_key.sql new file mode 100644 index 0000000..ce8b9ed --- /dev/null +++ b/server/database/migrations/0002_refactor_roles_primary_key.sql @@ -0,0 +1,58 @@ +-- Migration: Refactor roles table to use code as primary key +-- This migration drops and recreates the role-related tables with the new schema + +-- Drop existing tables (CASCADE removes dependent foreign keys) +DROP TABLE IF EXISTS "product_role_visibility" CASCADE;--> statement-breakpoint +DROP TABLE IF EXISTS "user_roles" CASCADE;--> statement-breakpoint +DROP TABLE IF EXISTS "roles" CASCADE;--> statement-breakpoint + +-- Recreate roles table with code as primary key +CREATE TABLE "roles" ( + "code" "role_code" PRIMARY KEY 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 +); +--> statement-breakpoint + +-- Recreate user_roles table with roleCode instead of roleId +CREATE TABLE "user_roles" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "role_code" "role_code" 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 + +-- Recreate product_role_visibility table with roleCode instead of roleId +CREATE TABLE "product_role_visibility" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "product_id" uuid NOT NULL, + "role_code" "role_code" NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint + +-- Add foreign key constraints +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_code_roles_code_fk" FOREIGN KEY ("role_code") REFERENCES "public"."roles"("code") ON DELETE cascade ON UPDATE no action;--> 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_code_roles_code_fk" FOREIGN KEY ("role_code") REFERENCES "public"."roles"("code") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint + +-- Create indexes +CREATE INDEX "user_roles_user_id_role_code_unique" ON "user_roles" USING btree ("user_id","role_code");--> statement-breakpoint +CREATE INDEX "user_roles_user_id_idx" ON "user_roles" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "user_roles_role_code_idx" ON "user_roles" USING btree ("role_code");--> statement-breakpoint +CREATE INDEX "user_roles_status_idx" ON "user_roles" USING btree ("status");--> statement-breakpoint +CREATE INDEX "product_role_visibility_product_id_role_code_unique" ON "product_role_visibility" USING btree ("product_id","role_code");--> statement-breakpoint +CREATE INDEX "product_role_visibility_product_id_idx" ON "product_role_visibility" USING btree ("product_id");--> statement-breakpoint +CREATE INDEX "product_role_visibility_role_code_idx" ON "product_role_visibility" USING btree ("role_code"); diff --git a/server/database/migrations/meta/_journal.json b/server/database/migrations/meta/_journal.json index ebc6f2c..911a69d 100644 --- a/server/database/migrations/meta/_journal.json +++ b/server/database/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1762074397305, "tag": "0001_clammy_bulldozer", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1762157410000, + "tag": "0002_refactor_roles_primary_key", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/database/schema.ts b/server/database/schema.ts index 9dd2e21..857f0c7 100644 --- a/server/database/schema.ts +++ b/server/database/schema.ts @@ -160,10 +160,10 @@ export const products = pgTable( * Roles Table * Defines available user roles (private, educator, company) * Phase 2/3: Educator and Company roles require approval workflow + * Note: Using role code as primary key for better readability in junction tables */ export const roles = pgTable('roles', { - id: uuid('id').primaryKey().defaultRandom(), - code: roleCodeEnum('code').unique().notNull(), // 'private', 'educator', 'company' + code: roleCodeEnum('code').primaryKey(), // 'private', 'educator', 'company' - Primary Key displayName: text('display_name').notNull(), // "Privatperson", "Pädagoge", "Unternehmen" description: text('description').notNull(), // Role description requiresApproval: boolean('requires_approval').notNull().default(false), // false for 'private', true for 'educator'/'company' @@ -186,9 +186,9 @@ export const userRoles = pgTable( userId: uuid('user_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), - roleId: uuid('role_id') + roleCode: roleCodeEnum('role_code') .notNull() - .references(() => roles.id, { onDelete: 'cascade' }), + .references(() => roles.code, { onDelete: 'cascade' }), // Role request status (Phase 2/3 feature - prepared in MVP) status: roleRequestStatusEnum('status').notNull().default('pending'), @@ -206,8 +206,9 @@ export const userRoles = pgTable( }, (table) => [ // Unique constraint: User can only have one entry per role - index('user_roles_user_id_role_id_unique').on(table.userId, table.roleId), + index('user_roles_user_id_role_code_unique').on(table.userId, table.roleCode), index('user_roles_user_id_idx').on(table.userId), + index('user_roles_role_code_idx').on(table.roleCode), index('user_roles_status_idx').on(table.status), ] ) @@ -225,15 +226,16 @@ export const productRoleVisibility = pgTable( productId: uuid('product_id') .notNull() .references(() => products.id, { onDelete: 'cascade' }), - roleId: uuid('role_id') + roleCode: roleCodeEnum('role_code') .notNull() - .references(() => roles.id, { onDelete: 'cascade' }), + .references(() => roles.code, { onDelete: 'cascade' }), createdAt: timestamp('created_at').defaultNow().notNull(), }, (table) => [ // Unique constraint: Product-Role pair can only exist once - index('product_role_visibility_product_id_role_id_unique').on(table.productId, table.roleId), + index('product_role_visibility_product_id_role_code_unique').on(table.productId, table.roleCode), index('product_role_visibility_product_id_idx').on(table.productId), + index('product_role_visibility_role_code_idx').on(table.roleCode), ] ) @@ -381,8 +383,8 @@ export const userRolesRelations = relations(userRoles, ({ one }) => ({ references: [users.id], }), role: one(roles, { - fields: [userRoles.roleId], - references: [roles.id], + fields: [userRoles.roleCode], + references: [roles.code], }), })) @@ -392,7 +394,7 @@ export const productRoleVisibilityRelations = relations(productRoleVisibility, ( references: [products.id], }), role: one(roles, { - fields: [productRoleVisibility.roleId], - references: [roles.id], + fields: [productRoleVisibility.roleCode], + references: [roles.code], }), })) diff --git a/server/database/seed.ts b/server/database/seed.ts index c605220..66a7160 100644 --- a/server/database/seed.ts +++ b/server/database/seed.ts @@ -173,7 +173,7 @@ async function seed() { const roleCodes = productData.roles || [] for (const roleCode of roleCodes) { - // Find role by code + // Find role display name for logging const role = insertedRoles.find((r) => r.code === roleCode) if (!role) { console.warn(` ⚠️ Role '${roleCode}' not found for product ${product.name}`) @@ -184,14 +184,14 @@ async function seed() { const existing = await db.query.productRoleVisibility.findFirst({ where: and( eq(productRoleVisibility.productId, product.id), - eq(productRoleVisibility.roleId, role.id) + eq(productRoleVisibility.roleCode, roleCode) ), }) if (!existing) { await db.insert(productRoleVisibility).values({ productId: product.id, - roleId: role.id, + roleCode, // Direct reference to roles.code (PK) }) assignmentCount++ console.log(` - ${product.name} → ${role.displayName}`) diff --git a/server/utils/roles.ts b/server/utils/roles.ts index a99c908..d05d04e 100644 --- a/server/utils/roles.ts +++ b/server/utils/roles.ts @@ -99,21 +99,21 @@ export async function isProductVisibleForUser( export async function getVisibleProductIdsForUser(userId: string): Promise { const db = useDatabase() - // Get user's approved role IDs + // Get user's approved role codes const userRoleRecords = await db.query.userRoles.findMany({ where: and(eq(userRoles.userId, userId), eq(userRoles.status, 'approved')), }) - const userRoleIds = userRoleRecords.map((ur) => ur.roleId) + const userRoleCodes = userRoleRecords.map((ur) => ur.roleCode) // If user has no approved roles, they can't see any products - if (userRoleIds.length === 0) { + if (userRoleCodes.length === 0) { return [] } // Get all products assigned to these roles const visibleProducts = await db.query.productRoleVisibility.findMany({ - where: inArray(productRoleVisibility.roleId, userRoleIds), + where: inArray(productRoleVisibility.roleCode, userRoleCodes), }) // Return unique product IDs @@ -138,18 +138,9 @@ export async function assignRoleToUser( ) { 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)), + where: and(eq(userRoles.userId, userId), eq(userRoles.roleCode, roleCode)), }) if (existing) { @@ -157,11 +148,12 @@ export async function assignRoleToUser( } // Create user role assignment (approved in MVP) + // Note: No need to lookup role - we can use roleCode directly as FK const [userRole] = await db .insert(userRoles) .values({ userId, - roleId: role.id, + roleCode, // Direct reference to roles.code (PK) status: 'approved', // MVP: Always approved organizationName: options?.organizationName, adminNotes: options?.adminNotes, @@ -227,20 +219,16 @@ export async function assignRolesToProductByCategory( 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) { + // Note: No need to lookup roles - we can use roleCode directly as FK + for (const roleCode of roleCodes) { // Check if assignment already exists const existing = await db.query.productRoleVisibility.findFirst({ where: and( eq(productRoleVisibility.productId, productId), - eq(productRoleVisibility.roleId, role.id) + eq(productRoleVisibility.roleCode, roleCode) ), }) @@ -249,7 +237,7 @@ export async function assignRolesToProductByCategory( .insert(productRoleVisibility) .values({ productId, - roleId: role.id, + roleCode, // Direct reference to roles.code (PK) }) .returning()