Browse Source

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.
main
Bastian Masanek 2 months ago
parent
commit
ff9960edef
  1. 145
      CLAUDE.md
  2. 41
      docs/ARCHITECTURE.md
  3. 85
      docs/PRD.md
  4. 36
      server/api/products/index.get.ts
  5. 43
      server/database/migrations/0001_clammy_bulldozer.sql
  6. 1000
      server/database/migrations/meta/0001_snapshot.json
  7. 7
      server/database/migrations/meta/_journal.json
  8. 126
      server/database/schema.ts
  9. 147
      server/database/seed.ts
  10. 261
      server/utils/roles.ts

145
CLAUDE.md

@ -178,6 +178,14 @@ See `docs/ARCHITECTURE.md` for full schema. Key tables:
- `products` - Products synced from NAV ERP - `products` - Products synced from NAV ERP
- `carts` / `cart_items` - Shopping cart - `carts` / `cart_items` - Shopping cart
- `orders` / `order_items` - Orders and line items - `orders` / `order_items` - Orders and line items
- **`roles`** - Role definitions (private, educator, company)
- **`user_roles`** - Many-to-Many User ↔ Roles (with approval workflow prepared for Phase 2/3)
- **`product_role_visibility`** - Many-to-Many Product ↔ Roles (controls product visibility)
**Role-based Visibility (MVP):**
- Products are ONLY visible if they have `product_role_visibility` entries
- Users ONLY see products matching their approved roles in `user_roles`
- Opt-in visibility: No role assignment = invisible to everyone
Use Drizzle ORM for all database operations. All tables use UUID primary keys. Use Drizzle ORM for all database operations. All tables use UUID primary keys.
@ -488,6 +496,143 @@ export async function submitOrderToXAPI(payload: XAPIOrderPayload) {
- Line numbers: Sequential multiples of 10000 - Line numbers: Sequential multiples of 10000
- Salutation: 'male' → 'HERR', 'female' → 'FRAU', other → 'K_ANGABE' - Salutation: 'male' → 'HERR', 'female' → 'FRAU', other → 'K_ANGABE'
## Role-based Product Visibility Patterns (MVP)
### Role-based Filtering Pattern
```typescript
// Server-side: GET /api/products - Filter products by user roles
import { getVisibleProductIdsForUser } from '~/server/utils/roles'
export default defineEventHandler(async (event) => {
const { user } = await getUserSession(event)
// MVP: Unauthenticated users see NO products (opt-in visibility)
if (!user) {
return []
}
// Get product IDs visible to this user (based on approved roles)
const visibleProductIds = await getVisibleProductIdsForUser(user.id)
if (visibleProductIds.length === 0) {
return []
}
// Fetch products with role-based filtering
const products = await db.query.products.findMany({
where: and(
eq(products.active, true),
inArray(products.id, visibleProductIds) // Role filter
)
})
return products
})
```
### ERP Category to Role Mapping Pattern
```typescript
// Auto-assign roles when importing products from NAV ERP
import { assignRolesToProductByCategory } from '~/server/utils/roles'
// server/api/erp/products.post.ts
export default defineEventHandler(async (event) => {
const { navProductId, category, ...productData } = await readBody(event)
// 1. Create/update product
const [product] = await db.insert(products)
.values({ navProductId, category, ...productData })
.onConflictDoUpdate({ target: products.navProductId, set: productData })
.returning()
// 2. Auto-assign roles based on category mapping
await assignRolesToProductByCategory(product.id, category)
return product
})
```
**Category Mapping:**
```typescript
// Defined in server/utils/roles.ts
const categoryRoleMapping = {
'makerspace-annual-pass': ['private', 'educator'],
'annual-pass': ['private'],
'educator-annual-pass': ['educator'],
'company-annual-pass': ['company']
}
```
### Get User Approved Roles Pattern
```typescript
// server/utils/roles.ts - Utility functions
import { getUserApprovedRoles, getUserApprovedRoleCodes } from '~/server/utils/roles'
// Get full role objects
const roles = await getUserApprovedRoles(userId)
// => [{ id: '...', code: 'private', displayName: 'Privatperson', ... }]
// Get just role codes (lightweight)
const roleCodes = await getUserApprovedRoleCodes(userId)
// => ['private', 'educator']
```
### Manual Role Assignment Pattern (MVP)
```typescript
// server/utils/roles.ts - For manual role assignment
import { assignRoleToUser } from '~/server/utils/roles'
// Assign role to user (MVP: always approved)
await assignRoleToUser(userId, 'private')
// With optional metadata (prepared for Phase 2/3)
await assignRoleToUser(userId, 'educator', {
organizationName: 'Hölderlin-Gymnasium Heilbronn',
adminNotes: 'Verified via school email domain'
})
```
### Check Product Visibility Pattern
```typescript
// server/utils/roles.ts - Check if specific product is visible
import { isProductVisibleForUser } from '~/server/utils/roles'
const canView = await isProductVisibleForUser(productId, userId)
// => true if user has approved role matching product's role assignments
// => false if product has no role assignments (opt-in!) or user lacks role
```
### JSONB Status History Pattern (Phase 2/3 prepared)
```typescript
// user_roles.statusHistory stores complete audit trail
const historyEntry = {
status: 'approved',
organizationName: 'Hölderlin-Gymnasium',
adminNotes: 'Verified via Lehrerausweis',
changedAt: new Date().toISOString(),
changedBy: adminUserId // null for auto-assignments
}
await db.update(userRoles)
.set({
status: 'approved',
statusHistory: sql`${userRoles.statusHistory} || ${JSON.stringify(historyEntry)}::jsonb`
})
.where(eq(userRoles.id, userRoleId))
```
**Important Visibility Rules (MVP):**
- Unauthenticated users → See NO products
- User without approved roles → See NO products
- Product without role assignments → Visible to NO ONE (opt-in)
- Product with role assignments → Visible only to users with matching approved role
## Authentication Patterns ## Authentication Patterns
See [`docs/CIDAAS_INTEGRATION.md`](./docs/CIDAAS_INTEGRATION.md) for complete Cidaas OAuth2 implementation guide. See [`docs/CIDAAS_INTEGRATION.md`](./docs/CIDAAS_INTEGRATION.md) for complete Cidaas OAuth2 implementation guide.

41
docs/ARCHITECTURE.md

@ -1505,8 +1505,49 @@ try {
│ price │ │ price │
│ created_at │ │ created_at │
└─────────────────────┘ └─────────────────────┘
┌─────────────────────┐
│ Role │
├─────────────────────┤
│ id (PK) │
│ code (UQ) │ ('private', 'educator', 'company')
│ display_name │
│ description │
│ requires_approval │
│ sort_order │
│ active │
│ created_at │
│ updated_at │
└──────────┬──────────┘
│ M:N
┌──────────▼──────────┐ ┌─────────────────────┐
│ UserRole │ │ ProductRoleVis... │
├─────────────────────┤ ├─────────────────────┤
│ id (PK) │ │ id (PK) │
│ user_id (FK) ───────┼────> │ product_id (FK) ────┼────> Product
│ role_id (FK) ───────┼────> │ role_id (FK) ───────┼────> Role
│ status │ │ created_at │
│ organization_name │ └─────────────────────┘
│ admin_notes │
│ status_history │ (JSONB)
│ created_at │
│ updated_at │
└─────────────────────┘
``` ```
**Rollen-System (MVP - Datenbankstruktur):**
- **roles**: Rollen-Definitionen (private, educator, company)
- **user_roles**: Many-to-Many User ↔ Rollen mit Antrags-Workflow (vorbereitet für Phase 2/3)
- **product_role_visibility**: Many-to-Many Produkt ↔ Rollen (Sichtbarkeitssteuerung)
**Opt-in Sichtbarkeit:**
- Produkte OHNE `product_role_visibility` Einträge sind für NIEMANDEN sichtbar
- Produkte MIT Einträgen sind nur für User mit passender `approved` Rolle sichtbar
### 4.2 Drizzle Schema Definition ### 4.2 Drizzle Schema Definition
```typescript ```typescript

85
docs/PRD.md

@ -57,6 +57,8 @@ Eine spezialisierte E-Commerce-App, die es Besuchern des experimenta Science Cen
**Im Scope (MVP):** **Im Scope (MVP):**
- Registrierung und Login - Registrierung und Login
- Rollen-Datenstruktur (private, educator, company)
- Rollenbasierte Produktsichtbarkeit
- Anzeige von Makerspace-Jahreskarten - Anzeige von Makerspace-Jahreskarten
- Warenkorb-Funktionalität - Warenkorb-Funktionalität
- Checkout-Prozess - Checkout-Prozess
@ -65,9 +67,10 @@ Eine spezialisierte E-Commerce-App, die es Besuchern des experimenta Science Cen
**Out of Scope (MVP):** **Out of Scope (MVP):**
- Rollen-System (Pädagogen, Unternehmen) - Rollen-Antrags-UI (Pädagogen, Unternehmen beantragen Rolle)
- Admin-Panel für Rollen-Genehmigung
- Pädagogische Jahreskarten - Pädagogische Jahreskarten
- Genehmigungsworkflows - Genehmigungsworkflows (UI)
- Platzreservierung - Platzreservierung
- Multi-Payment-Provider - Multi-Payment-Provider
- Laborkurse - Laborkurse
@ -100,6 +103,84 @@ Eine spezialisierte E-Commerce-App, die es Besuchern des experimenta Science Cen
- Corporate Events - Corporate Events
- Teambuilding-Maßnahmen - Teambuilding-Maßnahmen
### 3.3 Rollen & Berechtigungen
Das System nutzt ein rollenbasiertes Modell zur Steuerung der Produktsichtbarkeit.
#### 3.3.1 Verfügbare Rollen
**Private (Privatperson):**
- Code: `private`
- Beschreibung: Für private Besucher und Einzelpersonen
- Genehmigung: Keine Genehmigung erforderlich (auto-approved)
- Produkte: Makerspace-Jahreskarten, allgemeine Jahreskarten
**Educator (Pädagoge):**
- Code: `educator`
- Beschreibung: Für Lehrer, Erzieher und pädagogische Fachkräfte
- Genehmigung: Erforderlich (Phase 2/3)
- Produkte: Pädagogen-Jahreskarten, Makerspace-Jahreskarten
**Company (Unternehmen):**
- Code: `company`
- Beschreibung: Für Firmenkunden und B2B-Partner
- Genehmigung: Erforderlich (Phase 2/3)
- Produkte: Firmen-Jahreskarten (zukünftig)
#### 3.3.2 Produktsichtbarkeit (MVP)
**Grundprinzip:** Opt-in Sichtbarkeit
- Produkte sind NUR sichtbar, wenn explizit Rollen zugewiesen sind
- Produkte OHNE Rollenzuweisung sind für NIEMANDEN sichtbar
- User OHNE genehmigte Rolle sehen KEINE Produkte
- Unauthentifizierte User sehen KEINE Produkte
**Beispiele:**
- Makerspace-Jahreskarte → zugewiesen zu `private` + `educator` → sichtbar für beide Rollen
- Pädagogen-Jahreskarte → zugewiesen zu `educator` → nur für Pädagogen sichtbar
- Unzugewiesenes Produkt → für niemanden sichtbar
#### 3.3.3 Rollenzuweisung (MVP)
**Manuelle Zuweisung via Datenbank:**
- Rollen werden manuell via Drizzle Studio zugewiesen
- Status immer `approved` (keine Anträge im MVP)
- Standard: Neue User erhalten automatisch Rolle `private`
**Automatische Produkt-Rollen-Zuweisung (ERP-Import):**
Beim Import von Produkten aus dem NAV ERP werden Rollen basierend auf der Kategorie automatisch zugewiesen:
| Kategorie | Zugewiesene Rollen |
|-----------|-------------------|
| `makerspace-annual-pass` | `private`, `educator` |
| `annual-pass` | `private` |
| `educator-annual-pass` | `educator` |
| `company-annual-pass` | `company` |
#### 3.3.4 Antrags-Workflow (Phase 2/3)
**Future Feature:** Benutzer können zusätzliche Rollen beantragen.
**Prozess (geplant):**
1. User navigiert zu "Profil" → "Rollen verwalten"
2. User wählt Rolle (z.B. "Pädagoge")
3. User gibt Schule/Organisation an (Freitext oder Dropdown)
4. System erstellt Antrag mit Status `pending`
5. Admin prüft Antrag im Admin-Panel
6. Admin genehmigt (`approved`) oder lehnt ab (`rejected`)
7. Bei Genehmigung: User sieht nun Produkte für diese Rolle
8. Bei Ablehnung: User kann mit korrigierten Daten erneut beantragen
**Datenbank-Vorbereitung (MVP):**
- Tabelle `user_roles` enthält bereits Felder für Antrags-Workflow:
- `status`: `pending` | `approved` | `rejected`
- `organizationName`: Name der Schule/Firma (Freitext)
- `adminNotes`: Admin-Kommentare zur Genehmigung/Ablehnung
- `statusHistory`: JSONB-Array mit Änderungshistorie
- Diese Felder sind vorbereitet, aber im MVP ungenutzt
--- ---
## 4. User Stories & Use Cases ## 4. User Stories & Use Cases

36
server/api/products/index.get.ts

@ -1,15 +1,22 @@
/** /**
* GET /api/products * GET /api/products
* *
* Returns a list of all active products available for purchase. * Returns a list of products visible to the current user based on their roles.
* Products are sorted by category and name. *
* Role-based Visibility (MVP):
* - Unauthenticated users: See NO products (empty array)
* - Authenticated users: See products assigned to their approved roles
* - Products WITHOUT role assignments: NOT visible (opt-in visibility)
* *
* Query Parameters: * Query Parameters:
* - category: Filter by category (optional, comma-separated for multiple) * - category: Filter by category (optional, comma-separated for multiple)
*
* Phase 2/3: This will be extended with role request/approval workflow
*/ */
import { eq, and, inArray } from 'drizzle-orm' import { eq, and, inArray } from 'drizzle-orm'
import { products } from '../../database/schema' import { products } from '../../database/schema'
import { getVisibleProductIdsForUser } from '../../utils/roles'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const db = useDatabase() const db = useDatabase()
@ -17,8 +24,27 @@ export default defineEventHandler(async (event) => {
const categoryParam = query.category as string | undefined const categoryParam = query.category as string | undefined
try { try {
// Get user session (if authenticated)
const { user } = await getUserSession(event)
// MVP: Unauthenticated users cannot see any products
if (!user) {
return []
}
// Get product IDs visible to this user (based on approved roles)
const visibleProductIds = await getVisibleProductIdsForUser(user.id)
// If user has no approved roles or no products are assigned to their roles
if (visibleProductIds.length === 0) {
return []
}
// Build where conditions // Build where conditions
const conditions = [eq(products.active, true)] const conditions = [
eq(products.active, true),
inArray(products.id, visibleProductIds), // Role-based filtering
]
// Filter by category if provided // Filter by category if provided
if (categoryParam) { if (categoryParam) {
@ -27,12 +53,12 @@ export default defineEventHandler(async (event) => {
} }
// Fetch products with filters // Fetch products with filters
const allProducts = await db.query.products.findMany({ const visibleProducts = await db.query.products.findMany({
where: and(...conditions), where: and(...conditions),
orderBy: (products, { asc }) => [asc(products.category), asc(products.name)], orderBy: (products, { asc }) => [asc(products.category), asc(products.name)],
}) })
return allProducts return visibleProducts
} catch (error) { } catch (error) {
console.error('Error fetching products:', error) console.error('Error fetching products:', error)
throw createError({ throw createError({

43
server/database/migrations/0001_clammy_bulldozer.sql

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

1000
server/database/migrations/meta/0001_snapshot.json

File diff suppressed because it is too large

7
server/database/migrations/meta/_journal.json

@ -8,6 +8,13 @@
"when": 1761820599588, "when": 1761820599588,
"tag": "0000_tiresome_malcolm_colcord", "tag": "0000_tiresome_malcolm_colcord",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1762074397305,
"tag": "0001_clammy_bulldozer",
"breakpoints": true
} }
] ]
} }

126
server/database/schema.ts

@ -95,6 +95,16 @@ export const orderStatusEnum = pgEnum('order_status', [
'failed', '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 * Users Table
* Stores local user profiles linked to Cidaas authentication * 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 * Carts Table
* Shopping carts for both authenticated and guest users * Shopping carts for both authenticated and guest users
@ -232,6 +329,7 @@ export const orderItems = pgTable('order_items', {
export const usersRelations = relations(users, ({ many }) => ({ export const usersRelations = relations(users, ({ many }) => ({
carts: many(carts), carts: many(carts),
orders: many(orders), orders: many(orders),
userRoles: many(userRoles),
})) }))
export const cartsRelations = relations(carts, ({ one, many }) => ({ export const cartsRelations = relations(carts, ({ one, many }) => ({
@ -275,4 +373,32 @@ export const orderItemsRelations = relations(orderItems, ({ one }) => ({
export const productsRelations = relations(products, ({ many }) => ({ export const productsRelations = relations(products, ({ many }) => ({
cartItems: many(cartItems), cartItems: many(cartItems),
orderItems: many(orderItems), 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],
}),
})) }))

147
server/database/seed.ts

@ -7,8 +7,42 @@
import 'dotenv/config' import 'dotenv/config'
import { drizzle } from 'drizzle-orm/postgres-js' import { drizzle } from 'drizzle-orm/postgres-js'
import { and, eq } from 'drizzle-orm'
import postgres from 'postgres' import postgres from 'postgres'
import { products } from './schema' import * as schema from './schema'
import { products, roles, productRoleVisibility } from './schema'
/**
* Standard roles for the system
* MVP: All roles are created, but assignment is manual
* Phase 2/3: educator and company roles require approval workflow
*/
const standardRoles = [
{
code: 'private' as const,
displayName: 'Privatperson',
description: 'Für private Besucher und Einzelpersonen',
requiresApproval: false,
sortOrder: 1,
active: true,
},
{
code: 'educator' as const,
displayName: 'Pädagoge',
description: 'Für Lehrer, Erzieher und pädagogische Fachkräfte',
requiresApproval: true,
sortOrder: 2,
active: true,
},
{
code: 'company' as const,
displayName: 'Unternehmen',
description: 'Für Firmenkunden und B2B-Partner',
requiresApproval: true,
sortOrder: 3,
active: true,
},
]
/** /**
* Sample annual pass products for experimenta * Sample annual pass products for experimenta
@ -55,33 +89,108 @@ async function seed() {
console.log('🌱 Starting database seed...') console.log('🌱 Starting database seed...')
// Create database connection // Create database connection with schema
const client = postgres(connectionString) const client = postgres(connectionString)
const db = drizzle(client) const db = drizzle(client, { schema })
try { try {
// Insert products // 1. Insert/Update Roles
console.log(`📦 Inserting ${mockProducts.length} products...`) console.log(`👥 Inserting ${standardRoles.length} roles...`)
const insertedProducts = await db const insertedRoles = []
.insert(products) for (const roleData of standardRoles) {
.values(mockProducts) const [role] = await db
.onConflictDoUpdate({ .insert(roles)
target: products.navProductId, .values(roleData)
set: { .onConflictDoUpdate({
name: mockProducts[0].name, // Drizzle requires a set object, using sql.excluded in real impl target: roles.code,
description: mockProducts[0].description, set: {
price: mockProducts[0].price, displayName: roleData.displayName,
stockQuantity: mockProducts[0].stockQuantity, description: roleData.description,
updatedAt: new Date(), requiresApproval: roleData.requiresApproval,
}, sortOrder: roleData.sortOrder,
}) active: roleData.active,
.returning() updatedAt: new Date(),
},
})
.returning()
insertedRoles.push(role)
}
console.log(`✅ Successfully inserted/updated ${insertedRoles.length} roles:`)
insertedRoles.forEach((role) => {
console.log(
` - ${role.displayName} (${role.code}) ${role.requiresApproval ? '[requires approval]' : '[auto-approved]'}`
)
})
// 2. Insert/Update Products
console.log(`\n📦 Inserting ${mockProducts.length} products...`)
const insertedProducts = []
for (const productData of mockProducts) {
const [product] = await db
.insert(products)
.values(productData)
.onConflictDoUpdate({
target: products.navProductId,
set: {
name: productData.name,
description: productData.description,
price: productData.price,
stockQuantity: productData.stockQuantity,
category: productData.category,
active: productData.active,
updatedAt: new Date(),
},
})
.returning()
insertedProducts.push(product)
}
console.log(`✅ Successfully inserted/updated ${insertedProducts.length} products:`) console.log(`✅ Successfully inserted/updated ${insertedProducts.length} products:`)
insertedProducts.forEach((product) => { insertedProducts.forEach((product) => {
console.log(` - ${product.name} (${product.navProductId}) - €${product.price}`) console.log(` - ${product.name} (${product.navProductId}) - €${product.price}`)
}) })
// 3. Assign Roles to Products (Category-based mapping)
console.log(`\n🔗 Assigning roles to products based on category...`)
// Category to role mapping
const categoryRoleMapping: Record<string, string[]> = {
'makerspace-annual-pass': ['private', 'educator'],
'annual-pass': ['private'],
'educator-annual-pass': ['educator'],
}
let assignmentCount = 0
for (const product of insertedProducts) {
const roleCodes = categoryRoleMapping[product.category] || []
for (const roleCode of roleCodes) {
// Find role by code
const role = insertedRoles.find((r) => r.code === roleCode)
if (!role) continue
// Check if assignment already exists
const existing = await db.query.productRoleVisibility.findFirst({
where: and(
eq(productRoleVisibility.productId, product.id),
eq(productRoleVisibility.roleId, role.id)
),
})
if (!existing) {
await db.insert(productRoleVisibility).values({
productId: product.id,
roleId: role.id,
})
assignmentCount++
console.log(` - ${product.name}${role.displayName}`)
}
}
}
console.log(`✅ Created ${assignmentCount} product-role assignments`)
console.log('\n✨ Database seed completed successfully!') console.log('\n✨ Database seed completed successfully!')
} catch (error) { } catch (error) {
console.error('❌ Error seeding database:', error) console.error('❌ Error seeding database:', error)

261
server/utils/roles.ts

@ -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…
Cancel
Save