Browse Source

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.
main
Bastian Masanek 2 months ago
parent
commit
68ab42f2f4
  1. 58
      server/database/migrations/0002_refactor_roles_primary_key.sql
  2. 7
      server/database/migrations/meta/_journal.json
  3. 26
      server/database/schema.ts
  4. 6
      server/database/seed.ts
  5. 34
      server/utils/roles.ts

58
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");

7
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
}
]
}

26
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],
}),
}))

6
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}`)

34
server/utils/roles.ts

@ -99,21 +99,21 @@ export async function isProductVisibleForUser(
export async function getVisibleProductIdsForUser(userId: string): Promise<string[]> {
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()

Loading…
Cancel
Save