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:
145
CLAUDE.md
145
CLAUDE.md
@@ -178,6 +178,14 @@ See `docs/ARCHITECTURE.md` for full schema. Key tables:
|
||||
- `products` - Products synced from NAV ERP
|
||||
- `carts` / `cart_items` - Shopping cart
|
||||
- `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.
|
||||
|
||||
@@ -488,6 +496,143 @@ export async function submitOrderToXAPI(payload: XAPIOrderPayload) {
|
||||
- Line numbers: Sequential multiples of 10000
|
||||
- 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
|
||||
|
||||
See [`docs/CIDAAS_INTEGRATION.md`](./docs/CIDAAS_INTEGRATION.md) for complete Cidaas OAuth2 implementation guide.
|
||||
|
||||
@@ -1505,8 +1505,49 @@ try {
|
||||
|
||||
|
||||
┌─────────────────────┐
|
||||
│ 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
|
||||
|
||||
```typescript
|
||||
// server/database/schema.ts
|
||||
import {
|
||||
pgTable,
|
||||
|
||||
85
docs/PRD.md
85
docs/PRD.md
@@ -57,6 +57,8 @@ Eine spezialisierte E-Commerce-App, die es Besuchern des experimenta Science Cen
|
||||
**Im Scope (MVP):**
|
||||
|
||||
- Registrierung und Login
|
||||
- Rollen-Datenstruktur (private, educator, company)
|
||||
- Rollenbasierte Produktsichtbarkeit
|
||||
- Anzeige von Makerspace-Jahreskarten
|
||||
- Warenkorb-Funktionalität
|
||||
- Checkout-Prozess
|
||||
@@ -65,9 +67,10 @@ Eine spezialisierte E-Commerce-App, die es Besuchern des experimenta Science Cen
|
||||
|
||||
**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
|
||||
- Genehmigungsworkflows
|
||||
- Genehmigungsworkflows (UI)
|
||||
- Platzreservierung
|
||||
- Multi-Payment-Provider
|
||||
- Laborkurse
|
||||
@@ -100,6 +103,84 @@ Eine spezialisierte E-Commerce-App, die es Besuchern des experimenta Science Cen
|
||||
- Corporate Events
|
||||
- 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
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
/**
|
||||
* GET /api/products
|
||||
*
|
||||
* Returns a list of all active products available for purchase.
|
||||
* Products are sorted by category and name.
|
||||
* Returns a list of products visible to the current user based on their roles.
|
||||
*
|
||||
* 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:
|
||||
* - 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 { products } from '../../database/schema'
|
||||
import { getVisibleProductIdsForUser } from '../../utils/roles'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const db = useDatabase()
|
||||
@@ -17,8 +24,27 @@ export default defineEventHandler(async (event) => {
|
||||
const categoryParam = query.category as string | undefined
|
||||
|
||||
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
|
||||
const conditions = [eq(products.active, true)]
|
||||
const conditions = [
|
||||
eq(products.active, true),
|
||||
inArray(products.id, visibleProductIds), // Role-based filtering
|
||||
]
|
||||
|
||||
// Filter by category if provided
|
||||
if (categoryParam) {
|
||||
@@ -27,12 +53,12 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
// Fetch products with filters
|
||||
const allProducts = await db.query.products.findMany({
|
||||
const visibleProducts = await db.query.products.findMany({
|
||||
where: and(...conditions),
|
||||
orderBy: (products, { asc }) => [asc(products.category), asc(products.name)],
|
||||
})
|
||||
|
||||
return allProducts
|
||||
return visibleProducts
|
||||
} catch (error) {
|
||||
console.error('Error fetching products:', error)
|
||||
throw createError({
|
||||
|
||||
43
server/database/migrations/0001_clammy_bulldozer.sql
Normal file
43
server/database/migrations/0001_clammy_bulldozer.sql
Normal file
@@ -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
Normal file
1000
server/database/migrations/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,13 @@
|
||||
"when": 1761820599588,
|
||||
"tag": "0000_tiresome_malcolm_colcord",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1762074397305,
|
||||
"tag": "0001_clammy_bulldozer",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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],
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -7,8 +7,42 @@
|
||||
|
||||
import 'dotenv/config'
|
||||
import { drizzle } from 'drizzle-orm/postgres-js'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
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
|
||||
@@ -55,33 +89,108 @@ async function seed() {
|
||||
|
||||
console.log('🌱 Starting database seed...')
|
||||
|
||||
// Create database connection
|
||||
// Create database connection with schema
|
||||
const client = postgres(connectionString)
|
||||
const db = drizzle(client)
|
||||
const db = drizzle(client, { schema })
|
||||
|
||||
try {
|
||||
// Insert products
|
||||
console.log(`📦 Inserting ${mockProducts.length} products...`)
|
||||
const insertedProducts = await db
|
||||
.insert(products)
|
||||
.values(mockProducts)
|
||||
.onConflictDoUpdate({
|
||||
target: products.navProductId,
|
||||
set: {
|
||||
name: mockProducts[0].name, // Drizzle requires a set object, using sql.excluded in real impl
|
||||
description: mockProducts[0].description,
|
||||
price: mockProducts[0].price,
|
||||
stockQuantity: mockProducts[0].stockQuantity,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
.returning()
|
||||
// 1. Insert/Update Roles
|
||||
console.log(`👥 Inserting ${standardRoles.length} roles...`)
|
||||
const insertedRoles = []
|
||||
for (const roleData of standardRoles) {
|
||||
const [role] = await db
|
||||
.insert(roles)
|
||||
.values(roleData)
|
||||
.onConflictDoUpdate({
|
||||
target: roles.code,
|
||||
set: {
|
||||
displayName: roleData.displayName,
|
||||
description: roleData.description,
|
||||
requiresApproval: roleData.requiresApproval,
|
||||
sortOrder: roleData.sortOrder,
|
||||
active: roleData.active,
|
||||
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:`)
|
||||
insertedProducts.forEach((product) => {
|
||||
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!')
|
||||
} catch (error) {
|
||||
console.error('❌ Error seeding database:', error)
|
||||
|
||||
261
server/utils/roles.ts
Normal file
261
server/utils/roles.ts
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user