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:
Bastian Masanek
2025-11-02 10:17:40 +01:00
parent 6e4f858883
commit ff9960edef
10 changed files with 1865 additions and 26 deletions

View File

@@ -95,6 +95,16 @@ export const orderStatusEnum = pgEnum('order_status', [
'failed',
])
// Role codes for user roles
export const roleCodeEnum = pgEnum('role_code', ['private', 'educator', 'company'])
// Role request status (for approval workflow in Phase 2/3)
export const roleRequestStatusEnum = pgEnum('role_request_status', [
'pending',
'approved',
'rejected',
])
/**
* Users Table
* Stores local user profiles linked to Cidaas authentication
@@ -146,6 +156,93 @@ export const products = pgTable(
})
)
/**
* Roles Table
* Defines available user roles (private, educator, company)
* Phase 2/3: Educator and Company roles require approval workflow
*/
export const roles = pgTable('roles', {
id: uuid('id').primaryKey().defaultRandom(),
code: roleCodeEnum('code').unique().notNull(), // 'private', 'educator', 'company'
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'
sortOrder: integer('sort_order').notNull().default(0), // Display order
active: boolean('active').notNull().default(true), // Can be deactivated
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
})
/**
* User Roles Table (Junction Table)
* Many-to-Many relationship between users and roles
* MVP: Roles assigned manually via DB, always status='approved'
* Phase 2/3: Users can request roles, admin approves/rejects
*/
export const userRoles = pgTable(
'user_roles',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
roleId: uuid('role_id')
.notNull()
.references(() => roles.id, { onDelete: 'cascade' }),
// Role request status (Phase 2/3 feature - prepared in MVP)
status: roleRequestStatusEnum('status').notNull().default('pending'),
// Role request data (Phase 2/3 feature - prepared in MVP)
organizationName: text('organization_name'), // School/Company name (freetext in MVP, FK to organizations in Phase 2/3)
adminNotes: text('admin_notes'), // Admin comments on approval/rejection
// JSONB history of status changes (Phase 2/3 feature - prepared in MVP)
// Format: [{ status, organizationName, adminNotes, changedAt, changedBy }, ...]
statusHistory: jsonb('status_history').notNull().default('[]'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
(table) => ({
// Unique constraint: User can only have one entry per role
userIdRoleIdUnique: index('user_roles_user_id_role_id_unique').on(
table.userId,
table.roleId
),
userIdIdx: index('user_roles_user_id_idx').on(table.userId),
statusIdx: index('user_roles_status_idx').on(table.status),
})
)
/**
* Product Role Visibility Table (Junction Table)
* Many-to-Many relationship between products and roles
* Defines which roles can see which products
* Products WITHOUT role assignments are INVISIBLE (opt-in visibility)
*/
export const productRoleVisibility = pgTable(
'product_role_visibility',
{
id: uuid('id').primaryKey().defaultRandom(),
productId: uuid('product_id')
.notNull()
.references(() => products.id, { onDelete: 'cascade' }),
roleId: uuid('role_id')
.notNull()
.references(() => roles.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(table) => ({
// Unique constraint: Product-Role pair can only exist once
productIdRoleIdUnique: index('product_role_visibility_product_id_role_id_unique').on(
table.productId,
table.roleId
),
productIdIdx: index('product_role_visibility_product_id_idx').on(table.productId),
})
)
/**
* Carts Table
* Shopping carts for both authenticated and guest users
@@ -232,6 +329,7 @@ export const orderItems = pgTable('order_items', {
export const usersRelations = relations(users, ({ many }) => ({
carts: many(carts),
orders: many(orders),
userRoles: many(userRoles),
}))
export const cartsRelations = relations(carts, ({ one, many }) => ({
@@ -275,4 +373,32 @@ export const orderItemsRelations = relations(orderItems, ({ one }) => ({
export const productsRelations = relations(products, ({ many }) => ({
cartItems: many(cartItems),
orderItems: many(orderItems),
roleVisibility: many(productRoleVisibility),
}))
export const rolesRelations = relations(roles, ({ many }) => ({
userRoles: many(userRoles),
productVisibility: many(productRoleVisibility),
}))
export const userRolesRelations = relations(userRoles, ({ one }) => ({
user: one(users, {
fields: [userRoles.userId],
references: [users.id],
}),
role: one(roles, {
fields: [userRoles.roleId],
references: [roles.id],
}),
}))
export const productRoleVisibilityRelations = relations(productRoleVisibility, ({ one }) => ({
product: one(products, {
fields: [productRoleVisibility.productId],
references: [products.id],
}),
role: one(roles, {
fields: [productRoleVisibility.roleId],
references: [roles.id],
}),
}))