diff --git a/package-lock.json b/package-lock.json index 1e802b3..bfb8bcf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@tanstack/react-query": "^5.90.21", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.19", + "@xyflow/react": "^12.10.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -5179,6 +5180,15 @@ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, "node_modules/@types/d3-ease": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", @@ -5209,6 +5219,12 @@ "@types/d3-time": "*" } }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, "node_modules/@types/d3-shape": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", @@ -5230,6 +5246,25 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -5915,6 +5950,66 @@ "win32" ] }, + "node_modules/@xyflow/react": { + "version": "12.10.1", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz", + "integrity": "sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.75", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.75", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.75.tgz", + "integrity": "sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/abs-svg-path": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz", @@ -6836,6 +6931,12 @@ "url": "https://polar.sh/cva" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -7069,6 +7170,28 @@ "node": ">=12" } }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -7124,6 +7247,15 @@ "node": ">=12" } }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -7169,6 +7301,41 @@ "node": ">=12" } }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", diff --git a/package.json b/package.json index fe39dcd..a4c9075 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@tanstack/react-query": "^5.90.21", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.19", + "@xyflow/react": "^12.10.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 15ae0c0..2b638b7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -78,6 +78,20 @@ enum SkillLevel { LEAD } +// ─── RBAC ────────────────────────────────────────────── + +model OrgRolePermission { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + role Role + permission Permission + + @@unique([organizationId, role, permission]) + @@index([organizationId]) + @@map("org_role_permissions") +} + // ─── Organization ─────────────────────────────────────── model Organization { @@ -87,9 +101,16 @@ model Organization { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - users User[] - projects Project[] - automationRules AutomationRule[] + users User[] + projects Project[] + automationRules AutomationRule[] + rolePermissions OrgRolePermission[] + pipelineTemplates PipelineTemplate[] + deliverables Deliverable[] + deliverableStages DeliverableStage[] + invitations Invitation[] + customFieldDefs CustomFieldDefinition[] + notificationRules NotificationRule[] @@map("organizations") } @@ -121,6 +142,7 @@ model User { searchLogs SearchLog[] automationRules AutomationRule[] @relation("AutomationCreator") chatMessages ChatMessage[] + invitationsSent Invitation[] @relation("InvitedBy") @@map("users") } @@ -198,6 +220,64 @@ model PipelineStageDependency { @@map("pipeline_stage_dependencies") } +// ─── Dynamic Pipeline Templates (org-scoped) ─────────── + +model PipelineTemplate { + id String @id @default(cuid()) + name String + description String? + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + isArchived Boolean @default(false) + isDefault Boolean @default(false) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + stages PipelineStageDefinition[] + projects Project[] + + @@unique([organizationId, name]) + @@index([organizationId]) + @@map("pipeline_templates") +} + +model PipelineStageDefinition { + id String @id @default(cuid()) + pipelineId String + pipeline PipelineTemplate @relation(fields: [pipelineId], references: [id], onDelete: Cascade) + name String + slug String + order Int + isCriticalGate Boolean @default(false) + isOptional Boolean @default(false) + description String? + estimatedDays Float? + color String? + customStatuses Json? + + dependsOn PipelineStageDependencyV2[] @relation("DependsOnStageV2") + dependedBy PipelineStageDependencyV2[] @relation("PrerequisiteStageV2") + + deliverableStages DeliverableStage[] + + @@unique([pipelineId, slug]) + @@unique([pipelineId, order]) + @@map("pipeline_stage_definitions") +} + +model PipelineStageDependencyV2 { + id String @id @default(cuid()) + stageId String + prerequisiteId String + + stage PipelineStageDefinition @relation("DependsOnStageV2", fields: [stageId], references: [id], onDelete: Cascade) + prerequisite PipelineStageDefinition @relation("PrerequisiteStageV2", fields: [prerequisiteId], references: [id], onDelete: Cascade) + + @@unique([stageId, prerequisiteId]) + @@map("pipeline_stage_dependencies_v2") +} + // ─── Project ──────────────────────────────────────────── model Project { @@ -224,16 +304,21 @@ model Project { // pgvector embedding for semantic search (raw SQL — Prisma can't query this directly) embedding Unsupported("vector(768)")? + customFields Json? organizationId String organization Organization @relation(fields: [organizationId], references: [id]) + pipelineTemplateId String? + pipelineTemplate PipelineTemplate? @relation(fields: [pipelineTemplateId], references: [id]) + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deliverables Deliverable[] @@index([organizationId]) + @@index([pipelineTemplateId]) @@index([status]) @@map("projects") } @@ -256,9 +341,12 @@ model Deliverable { // pgvector embedding for semantic search (raw SQL — Prisma can't query this directly) embedding Unsupported("vector(768)")? + customFields Json? - projectId String - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + organizationId String? + organization Organization? @relation(fields: [organizationId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -266,6 +354,7 @@ model Deliverable { stages DeliverableStage[] @@index([projectId]) + @@index([organizationId]) @@index([status]) @@map("deliverables") } @@ -291,6 +380,12 @@ model DeliverableStage { templateId String template PipelineStageTemplate @relation(fields: [templateId], references: [id]) + stageDefinitionId String? + stageDefinition PipelineStageDefinition? @relation(fields: [stageDefinitionId], references: [id]) + + organizationId String? + organization Organization? @relation(fields: [organizationId], references: [id]) + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -300,6 +395,8 @@ model DeliverableStage { @@unique([deliverableId, templateId]) @@index([deliverableId]) + @@index([stageDefinitionId]) + @@index([organizationId]) @@index([status]) @@map("deliverable_stages") } @@ -464,6 +561,29 @@ enum ExecutionStatus { FAILURE } +enum Permission { + PROJECT_CREATE + PROJECT_UPDATE + PROJECT_DELETE + PROJECT_VIEW + DELIVERABLE_CREATE + DELIVERABLE_UPDATE + DELIVERABLE_DELETE + STAGE_UPDATE_STATUS + STAGE_ASSIGN + STAGE_SCHEDULE + REVISION_CREATE + REVISION_REVIEW + COMMENT_CREATE + COMMENT_DELETE_ANY + PIPELINE_MANAGE + USER_MANAGE + ROLE_MANAGE + ORG_SETTINGS + AUTOMATION_MANAGE + FIELD_CUSTOMIZE +} + // ─── Chat History (CLI Anything) ──────────────────────── model ChatMessage { @@ -484,6 +604,70 @@ model ChatMessage { @@map("chat_messages") } +// ─── Custom Fields ────────────────────────────────────── + +model CustomFieldDefinition { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + entityType String // "PROJECT" | "DELIVERABLE" + fieldName String + fieldType String // "TEXT" | "NUMBER" | "DATE" | "SELECT" | "BOOLEAN" + fieldOptions Json? // For SELECT type: { options: string[] } + isRequired Boolean @default(false) + order Int @default(0) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([organizationId, entityType, fieldName]) + @@index([organizationId]) + @@map("custom_field_definitions") +} + +// ─── Notification Rules ───────────────────────────────── + +model NotificationRule { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + name String + isEnabled Boolean @default(true) + event String // e.g. "STAGE_STATUS_CHANGE", "DEADLINE_APPROACHING", "REVISION_SUBMITTED" + conditions Json? // { field: string, operator: string, value: any }[] + channels Json // ["IN_APP", "EMAIL"] + recipientRoles Json // ["ADMIN", "PRODUCER"] or ["ASSIGNEE"] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organizationId]) + @@index([event]) + @@map("notification_rules") +} + +// ─── Invitations ──────────────────────────────────────── + +model Invitation { + id String @id @default(cuid()) + email String + role Role @default(ARTIST) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + invitedById String + invitedBy User @relation("InvitedBy", fields: [invitedById], references: [id]) + token String @unique @default(cuid()) + expiresAt DateTime + acceptedAt DateTime? + + createdAt DateTime @default(now()) + + @@unique([email, organizationId]) + @@index([organizationId]) + @@index([token]) + @@map("invitations") +} + // ─── Semantic Search (Phase 8.4) ──────────────────────── model SearchLog { diff --git a/prisma/seed.ts b/prisma/seed.ts index 5934cdf..6588e0e 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -438,6 +438,50 @@ async function main() { console.log(`Created ${assignmentCount} stage assignments`); + // ─── Phase 2: Seed RBAC Default Permissions ────────── + console.log("Seeding RBAC default permissions..."); + + const DEFAULT_PERMISSIONS: Record = { + ADMIN: [ + "PROJECT_CREATE", "PROJECT_UPDATE", "PROJECT_DELETE", "PROJECT_VIEW", + "DELIVERABLE_CREATE", "DELIVERABLE_UPDATE", "DELIVERABLE_DELETE", + "STAGE_UPDATE_STATUS", "STAGE_ASSIGN", "STAGE_SCHEDULE", + "REVISION_CREATE", "REVISION_REVIEW", + "COMMENT_CREATE", "COMMENT_DELETE_ANY", + "PIPELINE_MANAGE", "USER_MANAGE", "ROLE_MANAGE", "ORG_SETTINGS", + "AUTOMATION_MANAGE", "FIELD_CUSTOMIZE", + ], + PRODUCER: [ + "PROJECT_CREATE", "PROJECT_UPDATE", "PROJECT_VIEW", + "DELIVERABLE_CREATE", "DELIVERABLE_UPDATE", "DELIVERABLE_DELETE", + "STAGE_UPDATE_STATUS", "STAGE_ASSIGN", "STAGE_SCHEDULE", + "REVISION_CREATE", "REVISION_REVIEW", + "COMMENT_CREATE", "COMMENT_DELETE_ANY", + "AUTOMATION_MANAGE", + ], + ARTIST: [ + "PROJECT_VIEW", + "STAGE_UPDATE_STATUS", + "REVISION_CREATE", + "COMMENT_CREATE", + ], + }; + + // Clear existing and re-seed + await prisma.orgRolePermission.deleteMany({ + where: { organizationId: devOrg.id }, + }); + + const permData: { organizationId: string; role: any; permission: any }[] = []; + for (const [role, permissions] of Object.entries(DEFAULT_PERMISSIONS)) { + for (const permission of permissions) { + permData.push({ organizationId: devOrg.id, role, permission }); + } + } + + await prisma.orgRolePermission.createMany({ data: permData }); + console.log(`Created ${permData.length} role permission entries for dev org`); + console.log("Seed complete!"); } diff --git a/scripts/backfill-org-ids.ts b/scripts/backfill-org-ids.ts new file mode 100644 index 0000000..dc622fe --- /dev/null +++ b/scripts/backfill-org-ids.ts @@ -0,0 +1,99 @@ +/** + * Backfill Organization IDs Script + * + * Populates the new organizationId columns on Deliverable and DeliverableStage + * by copying from their parent Project's organizationId. + * + * Run with: npx tsx scripts/backfill-org-ids.ts + */ + +import "dotenv/config"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { PrismaClient } from "../src/generated/prisma/client"; + +const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! }); +const prisma = new PrismaClient({ adapter }); + +async function main() { + console.log("Backfilling organizationId on deliverables..."); + + // Get all deliverables missing organizationId + const deliverables = await prisma.deliverable.findMany({ + where: { organizationId: null }, + select: { id: true, projectId: true }, + }); + + if (deliverables.length === 0) { + console.log("No deliverables need backfill."); + } else { + // Group by projectId for efficiency + const projectIds = [...new Set(deliverables.map((d) => d.projectId))]; + const projects = await prisma.project.findMany({ + where: { id: { in: projectIds } }, + select: { id: true, organizationId: true }, + }); + const projectOrgMap = new Map(projects.map((p) => [p.id, p.organizationId])); + + let updated = 0; + for (const deliverable of deliverables) { + const orgId = projectOrgMap.get(deliverable.projectId); + if (!orgId) { + console.warn(` Skipping deliverable ${deliverable.id} — project ${deliverable.projectId} has no org`); + continue; + } + await prisma.deliverable.update({ + where: { id: deliverable.id }, + data: { organizationId: orgId }, + }); + updated++; + } + console.log(` Updated ${updated}/${deliverables.length} deliverables.`); + } + + console.log("Backfilling organizationId on deliverable stages..."); + + const stages = await prisma.deliverableStage.findMany({ + where: { organizationId: null }, + select: { + id: true, + deliverable: { + select: { project: { select: { organizationId: true } } }, + }, + }, + }); + + if (stages.length === 0) { + console.log("No stages need backfill."); + } else { + let updated = 0; + // Batch update for efficiency + const batchSize = 100; + for (let i = 0; i < stages.length; i += batchSize) { + const batch = stages.slice(i, i + batchSize); + await prisma.$transaction( + batch.map((stage) => + prisma.deliverableStage.update({ + where: { id: stage.id }, + data: { organizationId: stage.deliverable.project.organizationId }, + }) + ) + ); + updated += batch.length; + if (updated % 500 === 0) { + console.log(` Progress: ${updated}/${stages.length}`); + } + } + console.log(` Updated ${updated}/${stages.length} stages.`); + } + + console.log("Backfill complete!"); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/scripts/migrate-to-dynamic-pipelines.ts b/scripts/migrate-to-dynamic-pipelines.ts new file mode 100644 index 0000000..cb77cdd --- /dev/null +++ b/scripts/migrate-to-dynamic-pipelines.ts @@ -0,0 +1,181 @@ +/** + * Migrate to Dynamic Pipelines + * + * Creates a PipelineTemplate "HP CG Standard" for the dev org, + * copies stages + dependencies from PipelineStageTemplate, + * backfills stageDefinitionId on existing DeliverableStages, + * and sets pipelineTemplateId on existing projects. + * + * Run with: npx tsx scripts/migrate-to-dynamic-pipelines.ts + */ + +import "dotenv/config"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { PrismaClient } from "../src/generated/prisma/client"; + +const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! }); +const prisma = new PrismaClient({ adapter }); + +const ORG_ID = "dev-org-001"; + +async function main() { + // 1. Check if already migrated + const existing = await prisma.pipelineTemplate.findFirst({ + where: { organizationId: ORG_ID, name: "HP CG Standard" }, + }); + + if (existing) { + console.log(`Pipeline template "HP CG Standard" already exists (${existing.id}). Skipping creation.`); + await backfillStages(existing.id); + await backfillProjects(existing.id); + console.log("Migration complete!"); + return; + } + + // 2. Fetch old global templates with dependencies + const oldTemplates = await prisma.pipelineStageTemplate.findMany({ + include: { dependsOn: true }, + orderBy: { order: "asc" }, + }); + + if (oldTemplates.length === 0) { + console.log("No old templates found. Run seed first."); + return; + } + + console.log(`Found ${oldTemplates.length} old pipeline stages. Creating dynamic template...`); + + // 3. Create the new PipelineTemplate + const pipeline = await prisma.pipelineTemplate.create({ + data: { + name: "HP CG Standard", + description: "Standard HP CG production pipeline with 10 stages", + organizationId: ORG_ID, + isDefault: true, + }, + }); + console.log(`Created PipelineTemplate: ${pipeline.id}`); + + // 4. Create PipelineStageDefinitions from old templates + const oldToNewId = new Map(); // old template ID → new definition ID + + for (const old of oldTemplates) { + const def = await prisma.pipelineStageDefinition.create({ + data: { + pipelineId: pipeline.id, + name: old.name, + slug: old.slug, + order: old.order, + isCriticalGate: old.isCriticalGate, + isOptional: old.isOptional, + description: old.description, + estimatedDays: old.estimatedDays, + }, + }); + oldToNewId.set(old.id, def.id); + console.log(` Stage: ${old.name} → ${def.id}`); + } + + // 5. Create dependencies + let depCount = 0; + for (const old of oldTemplates) { + for (const dep of old.dependsOn) { + const newStageId = oldToNewId.get(dep.stageId); + const newPrereqId = oldToNewId.get(dep.prerequisiteId); + if (!newStageId || !newPrereqId) { + console.warn(` Skipping dependency: ${dep.stageId} → ${dep.prerequisiteId}`); + continue; + } + await prisma.pipelineStageDependencyV2.create({ + data: { stageId: newStageId, prerequisiteId: newPrereqId }, + }); + depCount++; + } + } + console.log(`Created ${depCount} dependencies.`); + + // 6. Backfill existing data + await backfillStages(pipeline.id); + await backfillProjects(pipeline.id); + + console.log("Migration complete!"); +} + +async function backfillStages(pipelineId: string) { + // Get the stage definitions for this pipeline + const definitions = await prisma.pipelineStageDefinition.findMany({ + where: { pipelineId }, + }); + + // Get old templates to map slugs + const oldTemplates = await prisma.pipelineStageTemplate.findMany(); + const slugToDefId = new Map(definitions.map((d) => [d.slug, d.id])); + const oldIdToSlug = new Map(oldTemplates.map((t) => [t.id, t.slug])); + + // Find stages without stageDefinitionId + const stages = await prisma.deliverableStage.findMany({ + where: { stageDefinitionId: null }, + select: { id: true, templateId: true }, + }); + + if (stages.length === 0) { + console.log("No stages need stageDefinitionId backfill."); + return; + } + + console.log(`Backfilling stageDefinitionId on ${stages.length} stages...`); + let updated = 0; + const batchSize = 100; + + for (let i = 0; i < stages.length; i += batchSize) { + const batch = stages.slice(i, i + batchSize); + await prisma.$transaction( + batch + .map((stage) => { + const slug = oldIdToSlug.get(stage.templateId); + const defId = slug ? slugToDefId.get(slug) : undefined; + if (!defId) return null; + return prisma.deliverableStage.update({ + where: { id: stage.id }, + data: { stageDefinitionId: defId }, + }); + }) + .filter(Boolean) as any[] + ); + updated += batch.length; + } + console.log(` Updated ${updated} stages.`); +} + +async function backfillProjects(pipelineId: string) { + const projects = await prisma.project.findMany({ + where: { + organizationId: ORG_ID, + pipelineTemplateId: null, + }, + select: { id: true }, + }); + + if (projects.length === 0) { + console.log("No projects need pipelineTemplateId backfill."); + return; + } + + console.log(`Setting pipelineTemplateId on ${projects.length} projects...`); + await prisma.project.updateMany({ + where: { + id: { in: projects.map((p) => p.id) }, + }, + data: { pipelineTemplateId: pipelineId }, + }); + console.log(` Updated ${projects.length} projects.`); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/src/app/(app)/settings/fields/page.tsx b/src/app/(app)/settings/fields/page.tsx new file mode 100644 index 0000000..965b46f --- /dev/null +++ b/src/app/(app)/settings/fields/page.tsx @@ -0,0 +1,328 @@ +"use client"; + +import { useState } from "react"; +import { Columns, Plus, Trash2, Pencil, Check, X } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { + useCustomFields, + useCreateCustomField, + useUpdateCustomField, + useDeleteCustomField, +} from "@/hooks/use-custom-fields"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; + +const FIELD_TYPES = [ + { value: "TEXT", label: "Text", color: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400" }, + { value: "NUMBER", label: "Number", color: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300" }, + { value: "DATE", label: "Date", color: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300" }, + { value: "SELECT", label: "Select", color: "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300" }, + { value: "BOOLEAN", label: "Boolean", color: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300" }, +]; + +function getFieldTypeStyle(type: string) { + return FIELD_TYPES.find((t) => t.value === type)?.color || ""; +} + +function getFieldTypeLabel(type: string) { + return FIELD_TYPES.find((t) => t.value === type)?.label || type; +} + +type EntityType = "PROJECT" | "DELIVERABLE"; + +function FieldSection({ entityType, label }: { entityType: EntityType; label: string }) { + const [newFieldName, setNewFieldName] = useState(""); + const [newFieldType, setNewFieldType] = useState("TEXT"); + const [newIsRequired, setNewIsRequired] = useState(false); + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(""); + const [editType, setEditType] = useState(""); + const [editRequired, setEditRequired] = useState(false); + const [deleteConfirm, setDeleteConfirm] = useState(null); + + const { data: fields, isLoading } = useCustomFields(entityType); + const createField = useCreateCustomField(); + const updateField = useUpdateCustomField(); + const deleteField = useDeleteCustomField(); + + const handleCreate = async () => { + if (!newFieldName.trim()) return; + try { + await createField.mutateAsync({ + entityType, + fieldName: newFieldName.trim(), + fieldType: newFieldType, + isRequired: newIsRequired, + }); + setNewFieldName(""); + setNewFieldType("TEXT"); + setNewIsRequired(false); + toast.success("Field created"); + } catch (e: any) { + toast.error(e.message || "Failed to create field"); + } + }; + + const handleStartEdit = (field: any) => { + setEditingId(field.id); + setEditName(field.fieldName); + setEditType(field.fieldType); + setEditRequired(field.isRequired); + }; + + const handleSaveEdit = async () => { + if (!editingId || !editName.trim()) return; + try { + await updateField.mutateAsync({ + id: editingId, + fieldName: editName.trim(), + fieldType: editType, + isRequired: editRequired, + }); + setEditingId(null); + toast.success("Field updated"); + } catch (e: any) { + toast.error(e.message || "Failed to update field"); + } + }; + + const handleDelete = async (id: string) => { + try { + await deleteField.mutateAsync(id); + setDeleteConfirm(null); + toast.success("Field deleted"); + } catch { + toast.error("Failed to delete field"); + } + }; + + return ( + + + + {label} + + + + {/* Add field form */} +
+ setNewFieldName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleCreate()} + className="h-8 text-sm" + /> + + + +
+ + {/* Field list */} + {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : (fields as any[])?.length === 0 ? ( +

+ No {entityType.toLowerCase()} fields defined yet. Add your first field above. +

+ ) : ( +
+ {(fields as any[])?.map((field: any) => ( +
+ {editingId === field.id ? ( + /* Edit mode */ +
+ setEditName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSaveEdit()} + className="h-7 text-sm" + autoFocus + /> + + + + +
+ ) : ( + /* Display mode */ + <> +
+ {field.fieldName} + + {getFieldTypeLabel(field.fieldType)} + + {field.isRequired && ( + + Required + + )} + + #{field.order} + +
+
+ + +
+ + )} +
+ ))} +
+ )} +
+ + {/* Delete confirmation dialog */} + !open && setDeleteConfirm(null)}> + + + Delete Custom Field + +

+ This will permanently remove this field definition. Any existing data stored in this field will be lost. This action cannot be undone. +

+ + + + +
+
+
+ ); +} + +export default function CustomFieldsSettingsPage() { + return ( +
+
+ +
+

Custom Fields

+

+ Add custom metadata fields to projects and deliverables +

+
+
+ +
+ + +
+
+ ); +} diff --git a/src/app/(app)/settings/notifications/page.tsx b/src/app/(app)/settings/notifications/page.tsx new file mode 100644 index 0000000..bbac598 --- /dev/null +++ b/src/app/(app)/settings/notifications/page.tsx @@ -0,0 +1,391 @@ +"use client"; + +import { useState } from "react"; +import { Bell, Plus, Trash2 } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { + useNotificationRules, + useCreateNotificationRule, + useUpdateNotificationRule, + useDeleteNotificationRule, +} from "@/hooks/use-notification-rules"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; + +const EVENTS = [ + { value: "STAGE_STATUS_CHANGE", label: "Stage Status Change" }, + { value: "DEADLINE_APPROACHING", label: "Deadline Approaching" }, + { value: "DEADLINE_OVERDUE", label: "Deadline Overdue" }, + { value: "REVISION_SUBMITTED", label: "Revision Submitted" }, + { value: "REVISION_FEEDBACK", label: "Revision Feedback" }, + { value: "COMMENT_ADDED", label: "Comment Added" }, + { value: "ASSIGNMENT_CHANGE", label: "Assignment Change" }, +] as const; + +const CHANNELS = [ + { value: "IN_APP", label: "In-App" }, + { value: "EMAIL", label: "Email" }, +] as const; + +const RECIPIENT_ROLES = [ + { value: "ADMIN", label: "Admin" }, + { value: "PRODUCER", label: "Producer" }, + { value: "ARTIST", label: "Artist" }, + { value: "ASSIGNEE", label: "Assignee" }, +] as const; + +function getEventLabel(event: string) { + return EVENTS.find((e) => e.value === event)?.label || event; +} + +export default function NotificationRulesPage() { + const [showCreate, setShowCreate] = useState(false); + const [deleteConfirm, setDeleteConfirm] = useState(null); + + // Form state + const [formName, setFormName] = useState(""); + const [formEvent, setFormEvent] = useState(""); + const [formChannels, setFormChannels] = useState(["IN_APP"]); + const [formRoles, setFormRoles] = useState([]); + const [formEnabled, setFormEnabled] = useState(true); + + const { data: rules, isLoading } = useNotificationRules(); + const createRule = useCreateNotificationRule(); + const updateRule = useUpdateNotificationRule(); + const deleteRule = useDeleteNotificationRule(); + + const resetForm = () => { + setFormName(""); + setFormEvent(""); + setFormChannels(["IN_APP"]); + setFormRoles([]); + setFormEnabled(true); + }; + + const handleCreate = async () => { + if (!formName.trim() || !formEvent || formChannels.length === 0) return; + try { + await createRule.mutateAsync({ + name: formName.trim(), + event: formEvent, + channels: formChannels, + recipientRoles: formRoles, + isEnabled: formEnabled, + }); + resetForm(); + setShowCreate(false); + toast.success("Notification rule created"); + } catch (e: any) { + toast.error(e.message || "Failed to create rule"); + } + }; + + const handleToggleEnabled = async (ruleId: string, isEnabled: boolean) => { + try { + await updateRule.mutateAsync({ id: ruleId, isEnabled }); + } catch { + toast.error("Failed to update rule"); + } + }; + + const handleDelete = async (ruleId: string) => { + try { + await deleteRule.mutateAsync(ruleId); + setDeleteConfirm(null); + toast.success("Notification rule deleted"); + } catch { + toast.error("Failed to delete rule"); + } + }; + + const toggleChannel = (channel: string) => { + setFormChannels((prev) => + prev.includes(channel) + ? prev.filter((c) => c !== channel) + : [...prev, channel] + ); + }; + + const toggleRole = (role: string) => { + setFormRoles((prev) => + prev.includes(role) ? prev.filter((r) => r !== role) : [...prev, role] + ); + }; + + return ( +
+
+ +
+

+ Notification Rules +

+

+ Configure when and how your team gets notified about pipeline events +

+
+
+ + + + Rules + + + + {isLoading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ) : (rules as any[])?.length === 0 ? ( +

+ No notification rules yet. Create your first rule to get started. +

+ ) : ( +
+ {(rules as any[])?.map((rule: any) => ( +
+
+
+ + {rule.name} + + + {getEventLabel(rule.event)} + +
+
+ {(rule.channels as string[])?.map((ch: string) => ( + + {ch === "IN_APP" ? "In-App" : "Email"} + + ))} + {(rule.recipientRoles as string[])?.map((role: string) => ( + + {role} + + ))} +
+
+ +
+ + handleToggleEnabled(rule.id, checked) + } + /> + +
+
+ ))} +
+ )} +
+
+ + {/* Create Rule Dialog */} + !open && setShowCreate(false)} + > + + + Add Notification Rule + + +
+ {/* Name */} +
+ + setFormName(e.target.value)} + className="h-8 text-sm" + /> +
+ + {/* Event */} +
+ + +
+ + {/* Channels */} +
+ +
+ {CHANNELS.map((ch) => ( + + ))} +
+
+ + {/* Recipient Roles */} +
+ +
+ {RECIPIENT_ROLES.map((role) => ( + + ))} +
+
+ + {/* Enabled toggle */} +
+ + +
+
+ + + + + +
+
+ + {/* Delete confirmation dialog */} + !open && setDeleteConfirm(null)} + > + + + Delete Notification Rule + +

+ This will permanently remove this notification rule. This action + cannot be undone. +

+ + + + +
+
+
+ ); +} diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx index 859b53d..79e26b1 100644 --- a/src/app/(app)/settings/page.tsx +++ b/src/app/(app)/settings/page.tsx @@ -1,8 +1,38 @@ import Link from "next/link"; -import { Settings, Wrench } from "lucide-react"; +import { Settings, Wrench, Shield, GitBranch, Users, Columns, Bell } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; const settingsPages = [ + { + href: "/settings/pipelines", + label: "Pipeline Templates", + description: "Define and customize production pipelines — stages, dependencies, and gates", + icon: GitBranch, + }, + { + href: "/settings/permissions", + label: "Permissions", + description: "Configure what each role can do — manage access for Admins, Producers, and Artists", + icon: Shield, + }, + { + href: "/settings/team", + label: "Team", + description: "Manage team members and send invitations to join your organization", + icon: Users, + }, + { + href: "/settings/fields", + label: "Custom Fields", + description: "Add custom metadata fields to projects and deliverables", + icon: Columns, + }, + { + href: "/settings/notifications", + label: "Notification Rules", + description: "Configure when and how your team gets notified about pipeline events", + icon: Bell, + }, { href: "/settings/skills", label: "Skills Management", diff --git a/src/app/(app)/settings/permissions/page.tsx b/src/app/(app)/settings/permissions/page.tsx new file mode 100644 index 0000000..c759d51 --- /dev/null +++ b/src/app/(app)/settings/permissions/page.tsx @@ -0,0 +1,265 @@ +"use client"; + +import { useState } from "react"; +import { Shield, Check, X, Save } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Badge } from "@/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useOrgPermissions, useUpdateRolePermissions } from "@/hooks/use-permissions"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; + +const ROLES = ["ADMIN", "PRODUCER", "ARTIST"] as const; + +const ROLE_COLORS: Record = { + ADMIN: "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300", + PRODUCER: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300", + ARTIST: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300", +}; + +const PERMISSION_GROUPS: { label: string; permissions: { key: string; label: string; description: string }[] }[] = [ + { + label: "Projects", + permissions: [ + { key: "PROJECT_VIEW", label: "View", description: "View projects and dashboards" }, + { key: "PROJECT_CREATE", label: "Create", description: "Create new projects" }, + { key: "PROJECT_UPDATE", label: "Update", description: "Edit project details" }, + { key: "PROJECT_DELETE", label: "Delete", description: "Delete projects" }, + ], + }, + { + label: "Deliverables", + permissions: [ + { key: "DELIVERABLE_CREATE", label: "Create", description: "Create deliverables within projects" }, + { key: "DELIVERABLE_UPDATE", label: "Update", description: "Edit deliverable details" }, + { key: "DELIVERABLE_DELETE", label: "Delete", description: "Delete deliverables" }, + ], + }, + { + label: "Stages", + permissions: [ + { key: "STAGE_UPDATE_STATUS", label: "Update Status", description: "Change stage statuses" }, + { key: "STAGE_ASSIGN", label: "Assign", description: "Assign users to stages" }, + { key: "STAGE_SCHEDULE", label: "Schedule", description: "Override stage dates" }, + ], + }, + { + label: "Reviews", + permissions: [ + { key: "REVISION_CREATE", label: "Submit", description: "Submit revisions for review" }, + { key: "REVISION_REVIEW", label: "Review", description: "Approve or request changes" }, + ], + }, + { + label: "Comments", + permissions: [ + { key: "COMMENT_CREATE", label: "Create", description: "Post comments" }, + { key: "COMMENT_DELETE_ANY", label: "Delete Any", description: "Delete any user's comments" }, + ], + }, + { + label: "Administration", + permissions: [ + { key: "PIPELINE_MANAGE", label: "Pipelines", description: "Create and edit pipeline templates" }, + { key: "USER_MANAGE", label: "Users", description: "Manage team members and skills" }, + { key: "ROLE_MANAGE", label: "Roles", description: "Edit role permissions" }, + { key: "ORG_SETTINGS", label: "Org Settings", description: "View and modify organization settings" }, + { key: "AUTOMATION_MANAGE", label: "Automations", description: "Create and edit automation rules" }, + { key: "FIELD_CUSTOMIZE", label: "Custom Fields", description: "Define custom metadata fields" }, + ], + }, +]; + +export default function PermissionsPage() { + const { data: serverPerms, isLoading } = useOrgPermissions(); + const updatePerms = useUpdateRolePermissions(); + const [localPerms, setLocalPerms] = useState> | null>(null); + const [dirty, setDirty] = useState>(new Set()); + + // Initialize local state from server data + const perms = localPerms ?? (serverPerms + ? Object.fromEntries( + Object.entries(serverPerms).map(([role, perms]) => [role, new Set(perms)]) + ) + : null); + + const togglePermission = (role: string, permission: string) => { + if (role === "ADMIN") return; // ADMIN always has all permissions + + const current = perms + ? { ...perms } + : Object.fromEntries(ROLES.map((r) => [r, new Set()])); + + const rolePerms = new Set(current[role]); + if (rolePerms.has(permission)) { + rolePerms.delete(permission); + } else { + rolePerms.add(permission); + } + current[role] = rolePerms; + + setLocalPerms(current); + setDirty((prev) => new Set([...prev, role])); + }; + + const saveRole = async (role: string) => { + if (!perms) return; + try { + await updatePerms.mutateAsync({ + role, + permissions: Array.from(perms[role]), + }); + setDirty((prev) => { + const next = new Set(prev); + next.delete(role); + return next; + }); + toast.success(`${role} permissions updated`); + } catch (e: any) { + toast.error(e.message || "Failed to save permissions"); + } + }; + + const allPermissions = PERMISSION_GROUPS.flatMap((g) => g.permissions.map((p) => p.key)); + + return ( +
+
+ +
+

Permissions

+

+ Configure what each role can do in your organization +

+
+
+ + {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : ( + + + Role Permission Matrix + + +
+ + + + + {ROLES.map((role) => ( + + ))} + + + + {PERMISSION_GROUPS.map((group) => ( + <> + + + + {group.permissions.map((perm) => ( + + + {ROLES.map((role) => { + const isAdmin = role === "ADMIN"; + const hasIt = isAdmin + ? true + : perms?.[role]?.has(perm.key) ?? false; + + return ( + + ); + })} + + ))} + + ))} + +
+ Permission + +
+ + {role} + + {dirty.has(role) && role !== "ADMIN" && ( + + )} +
+
+ {group.label} +
+ + + + + {perm.label} + + + +

{perm.description}

+
+
+
+
+ +
+
+ +
+ +

+ Admin role always has all permissions and cannot be modified. + Changes are saved per-role and take effect immediately. +

+
+
+
+ )} +
+ ); +} diff --git a/src/app/(app)/settings/pipelines/[pipelineId]/page.tsx b/src/app/(app)/settings/pipelines/[pipelineId]/page.tsx new file mode 100644 index 0000000..e5bd250 --- /dev/null +++ b/src/app/(app)/settings/pipelines/[pipelineId]/page.tsx @@ -0,0 +1,273 @@ +"use client"; + +import { use, useState } from "react"; +import { useRouter } from "next/navigation"; +import { ArrowLeft, GitBranch, Plus, Star } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + usePipelineTemplate, + useUpdatePipeline, + useAddStage, + useUpdateStage, + useRemoveStage, + useReorderStages, + useAddDependency, + useRemoveDependency, + usePipelineValidation, +} from "@/hooks/use-pipelines"; +import { usePipelineBuilderStore } from "@/stores/pipeline-builder-store"; +import { PipelineStageList } from "@/components/pipeline-builder/pipeline-stage-list"; +import { PipelineGraph } from "@/components/pipeline-builder/pipeline-graph"; +import { StageEditSheet } from "@/components/pipeline-builder/stage-edit-sheet"; +import { PipelineValidationBanner } from "@/components/pipeline-builder/pipeline-validation-banner"; +import { toast } from "sonner"; +import Link from "next/link"; + +type PageProps = { params: Promise<{ pipelineId: string }> }; + +export default function PipelineEditorPage({ params }: PageProps) { + const { pipelineId } = use(params); + const router = useRouter(); + const { data: pipeline, isLoading } = usePipelineTemplate(pipelineId); + const { data: validation } = usePipelineValidation(pipelineId); + const updatePipeline = useUpdatePipeline(pipelineId); + const addStage = useAddStage(pipelineId); + const updateStage = useUpdateStage(pipelineId); + const removeStage = useRemoveStage(pipelineId); + const reorderStages = useReorderStages(pipelineId); + const addDependency = useAddDependency(pipelineId); + const removeDependency = useRemoveDependency(pipelineId); + + const { selectedStageId, selectStage } = usePipelineBuilderStore(); + const [showAddStage, setShowAddStage] = useState(false); + const [newStageName, setNewStageName] = useState(""); + + const pl = pipeline as any; + const stages = (pl?.stages ?? []) as any[]; + const selectedStage = stages.find((s: any) => s.id === selectedStageId) ?? null; + const val = validation as any; + + const handleAddStage = async () => { + if (!newStageName.trim()) return; + try { + await addStage.mutateAsync({ + name: newStageName.trim(), + order: stages.length + 1, + }); + setShowAddStage(false); + setNewStageName(""); + toast.success("Stage added"); + } catch (e: any) { + toast.error(e.message || "Failed to add stage"); + } + }; + + const handleUpdateStage = async (data: Record) => { + if (!selectedStageId) return; + try { + await updateStage.mutateAsync({ stageId: selectedStageId, ...data }); + toast.success("Stage updated"); + } catch (e: any) { + toast.error(e.message || "Failed to update stage"); + } + }; + + const handleDeleteStage = async () => { + if (!selectedStageId) return; + try { + await removeStage.mutateAsync(selectedStageId); + selectStage(null); + toast.success("Stage removed"); + } catch (e: any) { + toast.error(e.message || "Failed to remove stage"); + } + }; + + const handleReorder = async (stageIds: string[]) => { + try { + await reorderStages.mutateAsync(stageIds); + } catch (e: any) { + toast.error(e.message || "Failed to reorder"); + } + }; + + const handleConnect = async (stageId: string, prerequisiteId: string) => { + try { + await addDependency.mutateAsync({ stageId, prerequisiteId }); + toast.success("Dependency added"); + } catch (e: any) { + toast.error(e.message || "Failed to add dependency"); + } + }; + + const handleDeleteEdge = async (stageId: string, prerequisiteId: string) => { + try { + await removeDependency.mutateAsync({ stageId, prerequisiteId }); + toast.success("Dependency removed"); + } catch (e: any) { + toast.error(e.message || "Failed to remove dependency"); + } + }; + + const handleToggleDefault = async () => { + try { + await updatePipeline.mutateAsync({ isDefault: !pl?.isDefault }); + toast.success(pl?.isDefault ? "No longer default" : "Set as default"); + } catch (e: any) { + toast.error(e.message || "Failed to update"); + } + }; + + if (isLoading) { + return ( +
+ +
+ + +
+
+ ); + } + + if (!pl) { + return ( +
+ Pipeline not found. +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ + + + +
+

{pl.name}

+ {pl.description && ( +

{pl.description}

+ )} +
+ {pl.isDefault && ( + + + Default + + )} +
+
+ + + {pl._count?.projects ?? 0} projects + +
+
+ + {/* Validation banner */} + {val && ( + + )} + + {/* Two-panel layout */} +
+ {/* Left: Stage list */} +
+
+ + Stages + + + {stages.length} total + +
+ setShowAddStage(true)} + /> +
+ + {/* Right: Dependency graph */} +
+
+ + Dependency Graph + +
+
+ +
+
+
+ + {/* Stage edit sheet */} + !open && selectStage(null)} + onSave={handleUpdateStage} + onDelete={handleDeleteStage} + /> + + {/* Add stage dialog */} + + + + Add Stage + + setNewStageName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleAddStage()} + className="h-8 text-sm" + autoFocus + /> + + + + + + +
+ ); +} diff --git a/src/app/(app)/settings/pipelines/page.tsx b/src/app/(app)/settings/pipelines/page.tsx new file mode 100644 index 0000000..c3cd158 --- /dev/null +++ b/src/app/(app)/settings/pipelines/page.tsx @@ -0,0 +1,256 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { GitBranch, Plus, Copy, Archive, Star } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { + usePipelineTemplates, + useCreatePipeline, + useArchivePipeline, + useDuplicatePipeline, +} from "@/hooks/use-pipelines"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; + +export default function PipelinesPage() { + const { data: pipelines, isLoading } = usePipelineTemplates(); + const createPipeline = useCreatePipeline(); + const archivePipeline = useArchivePipeline(); + const duplicatePipeline = useDuplicatePipeline(); + + const [showCreate, setShowCreate] = useState(false); + const [newName, setNewName] = useState(""); + const [newDesc, setNewDesc] = useState(""); + const [dupDialog, setDupDialog] = useState<{ id: string; name: string } | null>(null); + const [dupName, setDupName] = useState(""); + const [archiveConfirm, setArchiveConfirm] = useState(null); + + const handleCreate = async () => { + if (!newName.trim()) return; + try { + await createPipeline.mutateAsync({ name: newName.trim(), description: newDesc.trim() || undefined }); + setShowCreate(false); + setNewName(""); + setNewDesc(""); + toast.success("Pipeline created"); + } catch (e: any) { + toast.error(e.message || "Failed to create pipeline"); + } + }; + + const handleDuplicate = async () => { + if (!dupDialog || !dupName.trim()) return; + try { + await duplicatePipeline.mutateAsync({ pipelineId: dupDialog.id, name: dupName.trim() }); + setDupDialog(null); + setDupName(""); + toast.success("Pipeline duplicated"); + } catch (e: any) { + toast.error(e.message || "Failed to duplicate"); + } + }; + + const handleArchive = async (id: string) => { + try { + await archivePipeline.mutateAsync(id); + setArchiveConfirm(null); + toast.success("Pipeline archived"); + } catch (e: any) { + toast.error(e.message || "Failed to archive"); + } + }; + + return ( +
+
+
+ +
+

Pipeline Templates

+

+ Define production pipelines for your organization +

+
+
+ +
+ + {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : (pipelines as any[])?.length === 0 ? ( + + + +

+ No pipeline templates yet. Create your first one to get started. +

+
+
+ ) : ( +
+ {(pipelines as any[])?.map((pipeline: any) => ( + + + + + {pipeline.name} + {pipeline.isDefault && ( + + + Default + + )} + + + + {pipeline.description && ( +

+ {pipeline.description} +

+ )} +
+ + {pipeline.stages?.length ?? 0} stages + + + {pipeline._count?.projects ?? 0} projects + +
+
+ + +
+ + +
+
+ ))} +
+ )} + + {/* Create dialog */} + + + + New Pipeline Template + +
+ setNewName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleCreate()} + className="h-8 text-sm" + /> + setNewDesc(e.target.value)} + className="h-8 text-sm" + /> +
+ + + + +
+
+ + {/* Duplicate dialog */} + !open && setDupDialog(null)}> + + + Duplicate Pipeline + + setDupName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleDuplicate()} + className="h-8 text-sm" + /> + + + + + + + + {/* Archive confirm dialog */} + !open && setArchiveConfirm(null)}> + + + Archive Pipeline + +

+ Archived pipelines won't appear in the list but existing projects using them won't be affected. +

+ + + + +
+
+
+ ); +} diff --git a/src/app/(app)/settings/team/page.tsx b/src/app/(app)/settings/team/page.tsx new file mode 100644 index 0000000..2de27c8 --- /dev/null +++ b/src/app/(app)/settings/team/page.tsx @@ -0,0 +1,271 @@ +"use client"; + +import { useState } from "react"; +import { + Users, + Mail, + UserPlus, + Trash2, + Clock, + Check, +} from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + useInvitations, + useCreateInvitation, + useRevokeInvitation, +} from "@/hooks/use-invitations"; +import { useQuery } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; + +const ROLE_STYLES: Record = { + ADMIN: "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300", + PRODUCER: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300", + ARTIST: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300", +}; + +function getStatusInfo(invitation: any) { + if (invitation.acceptedAt) { + return { + label: "Accepted", + className: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300", + icon: Check, + }; + } + if (new Date(invitation.expiresAt) < new Date()) { + return { + label: "Expired", + className: "bg-red-100 text-red-600 dark:bg-red-900/50 dark:text-red-400", + icon: Clock, + }; + } + return { + label: "Pending", + className: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300", + icon: Clock, + }; +} + +export default function TeamSettingsPage() { + const [inviteEmail, setInviteEmail] = useState(""); + const [inviteRole, setInviteRole] = useState("ARTIST"); + + const { data: users, isLoading: usersLoading } = useQuery({ + queryKey: ["users"], + queryFn: async () => { + const res = await fetch("/api/users"); + if (!res.ok) throw new Error("Failed to fetch users"); + return res.json(); + }, + }); + + const { data: invitations, isLoading: invitationsLoading } = useInvitations(); + const createInvitation = useCreateInvitation(); + const revokeInvitation = useRevokeInvitation(); + + const handleInvite = async () => { + const email = inviteEmail.trim(); + if (!email) return; + try { + await createInvitation.mutateAsync({ email, role: inviteRole }); + setInviteEmail(""); + toast.success("Invitation sent"); + } catch (e: any) { + toast.error(e.message || "Failed to send invitation"); + } + }; + + const handleRevoke = async (id: string) => { + try { + await revokeInvitation.mutateAsync(id); + toast.success("Invitation revoked"); + } catch { + toast.error("Failed to revoke invitation"); + } + }; + + return ( +
+
+ +
+

Team

+

+ Manage team members and send invitations to join your organization +

+
+
+ +
+ {/* Current Members */} + + + + + Current Members + + + + {usersLoading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ) : (users as any[])?.length === 0 ? ( +

+ No team members found. +

+ ) : ( + (users as any[])?.map((user: any) => ( +
+
+
+ + {user.name || "Unnamed"} + + + {user.role} + +
+
+ + {user.email} + {user.department && ( + <> + · + {user.department} + + )} +
+
+
+ )) + )} +
+
+ + {/* Invitations */} + + + + + Invitations + + + + {/* Invite form */} +
+ setInviteEmail(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleInvite()} + className="h-8 text-sm" + /> + + +
+ + {/* Invitations list */} + {invitationsLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : (invitations as any[])?.length === 0 ? ( +

+ No invitations sent yet. Invite your first team member above. +

+ ) : ( +
+ {(invitations as any[])?.map((inv: any) => { + const status = getStatusInfo(inv); + const StatusIcon = status.icon; + const isPending = !inv.acceptedAt && new Date(inv.expiresAt) >= new Date(); + + return ( +
+
+
+ {inv.email} + + {inv.role} + + + + {status.label} + +
+

+ Invited by {inv.invitedBy?.name || inv.invitedBy?.email || "Unknown"} +

+
+ {isPending && ( + + )} +
+ ); + })} +
+ )} +
+
+
+
+ ); +} diff --git a/src/app/api/automations/[ruleId]/executions/route.ts b/src/app/api/automations/[ruleId]/executions/route.ts index d8a69c8..f332aea 100644 --- a/src/app/api/automations/[ruleId]/executions/route.ts +++ b/src/app/api/automations/[ruleId]/executions/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { getAuthSession, serverError } from "@/lib/api-utils"; +import { serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; import { listExecutions } from "@/lib/services/automation-service"; export async function GET( @@ -7,7 +8,7 @@ export async function GET( { params }: { params: Promise<{ ruleId: string }> } ) { try { - const { session, error } = await getAuthSession(); + const { session, error } = await requireAuth("PROJECT_VIEW"); if (error) return error; const { ruleId } = await params; diff --git a/src/app/api/automations/[ruleId]/route.ts b/src/app/api/automations/[ruleId]/route.ts index 36afe73..04ce8e0 100644 --- a/src/app/api/automations/[ruleId]/route.ts +++ b/src/app/api/automations/[ruleId]/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { getAuthSession, notFound, serverError } from "@/lib/api-utils"; +import { notFound, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; import { getAutomationRule, updateAutomationRule, @@ -13,7 +14,7 @@ export async function GET( { params }: { params: Promise<{ ruleId: string }> } ) { try { - const { session, error } = await getAuthSession(); + const { session, error } = await requireAuth("PROJECT_VIEW"); if (error) return error; const { ruleId } = await params; @@ -32,13 +33,9 @@ export async function PATCH( { params }: { params: Promise<{ ruleId: string }> } ) { try { - const { session, error } = await getAuthSession(); + const { session, error } = await requireAuth("AUTOMATION_MANAGE"); if (error) return error; - if (session!.user.role === "ARTIST") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - const { ruleId } = await params; const body = await req.json(); @@ -74,13 +71,9 @@ export async function DELETE( { params }: { params: Promise<{ ruleId: string }> } ) { try { - const { session, error } = await getAuthSession(); + const { session, error } = await requireAuth("AUTOMATION_MANAGE"); if (error) return error; - if (session!.user.role === "ARTIST") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - const { ruleId } = await params; await deleteAutomationRule(ruleId); return NextResponse.json({ success: true }); diff --git a/src/app/api/automations/route.ts b/src/app/api/automations/route.ts index 4bbd5f8..d89e34f 100644 --- a/src/app/api/automations/route.ts +++ b/src/app/api/automations/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { getAuthSession, badRequest, serverError } from "@/lib/api-utils"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; import { listAutomationRules, createAutomationRule, @@ -9,10 +10,10 @@ import { validateActions } from "@/lib/automation/action-executor"; export async function GET() { try { - const { session, error } = await getAuthSession(); + const { session, error } = await requireAuth("PROJECT_VIEW"); if (error) return error; - const rules = await listAutomationRules(session!.user.organizationId!); + const rules = await listAutomationRules(session.user.organizationId); return NextResponse.json(rules); } catch (error) { return serverError(error); @@ -21,14 +22,9 @@ export async function GET() { export async function POST(req: NextRequest) { try { - const { session, error } = await getAuthSession(); + const { session, error } = await requireAuth("AUTOMATION_MANAGE"); if (error) return error; - // Only producers and admins can create rules - if (session!.user.role === "ARTIST") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - const body = await req.json(); const { name, description, trigger, actions, isEnabled } = body; @@ -57,8 +53,8 @@ export async function POST(req: NextRequest) { const rule = await createAutomationRule({ name, description, - organizationId: session!.user.organizationId!, - createdById: session!.user.id, + organizationId: session.user.organizationId, + createdById: session.user.id, trigger, actions, isEnabled, diff --git a/src/app/api/calendar/route.ts b/src/app/api/calendar/route.ts index e963f7a..d1251fd 100644 --- a/src/app/api/calendar/route.ts +++ b/src/app/api/calendar/route.ts @@ -1,11 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; import { getCalendarEvents } from "@/lib/services/calendar-service"; -import { getAuthSession } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; import type { StageStatus } from "@/generated/prisma/client"; export async function GET(req: NextRequest) { try { - const { session, error } = await getAuthSession(); + const { session, error } = await requireAuth("PROJECT_VIEW"); if (error) return error; const { searchParams } = new URL(req.url); diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 89e6e73..45b1efd 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { getAuthSession, serverError } from "@/lib/api-utils"; +import { serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; import { chat, buildSystemPrompt, getProviderStatus } from "@/lib/chat/provider"; import { executeTool } from "@/lib/chat/tool-executor"; import { prisma } from "@/lib/prisma"; @@ -231,7 +232,7 @@ function sendEvent( export async function POST(req: NextRequest) { try { - const { session, error } = await getAuthSession(); + const { session, error } = await requireAuth("PROJECT_VIEW"); if (error) return error; const { messages, context } = await req.json(); @@ -243,8 +244,8 @@ export async function POST(req: NextRequest) { ); } - const userId = session!.user.id; - const organizationId = session!.user.organizationId!; + const userId = session.user.id; + const organizationId = session.user.organizationId; // -- Build system prompt with optional page context -- let pageContext: string | undefined; @@ -452,7 +453,7 @@ export async function POST(req: NextRequest) { */ export async function GET() { try { - const { session, error } = await getAuthSession(); + const { session, error } = await requireAuth("PROJECT_VIEW"); if (error) return error; const status = await getProviderStatus(); diff --git a/src/app/api/dashboard/deadlines/route.ts b/src/app/api/dashboard/deadlines/route.ts index 1b283e5..fda75d9 100644 --- a/src/app/api/dashboard/deadlines/route.ts +++ b/src/app/api/dashboard/deadlines/route.ts @@ -1,15 +1,16 @@ import { NextResponse } from "next/server"; -import { getAuthSession, serverError } from "@/lib/api-utils"; +import { serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; import { checkDeadlines, generateDeadlineNotifications } from "@/lib/services/deadline-service"; // GET /api/dashboard/deadlines — check approaching and overdue deadlines // ?notify=true will also generate notifications export async function GET(request: Request) { - const { session, error } = await getAuthSession(); + const { session, error } = await requireAuth("PROJECT_VIEW"); if (error) return error; try { - const organizationId = (session as any)?.user?.organizationId; + const organizationId = session.user.organizationId; if (!organizationId) { return NextResponse.json({ approaching: [], overdue: [] }); } diff --git a/src/app/api/dashboard/stats/route.ts b/src/app/api/dashboard/stats/route.ts index a8b2a1c..5aaf678 100644 --- a/src/app/api/dashboard/stats/route.ts +++ b/src/app/api/dashboard/stats/route.ts @@ -1,14 +1,15 @@ import { NextResponse } from "next/server"; -import { getAuthSession, serverError } from "@/lib/api-utils"; +import { serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; import { getDashboardStats } from "@/lib/services/dashboard-service"; // GET /api/dashboard/stats export async function GET() { - const { session, error } = await getAuthSession(); + const { session, error } = await requireAuth("PROJECT_VIEW"); if (error) return error; try { - const organizationId = (session as any)?.user?.organizationId; + const organizationId = session.user.organizationId; if (!organizationId) { // If no org, return empty stats diff --git a/src/app/api/invitations/accept/route.ts b/src/app/api/invitations/accept/route.ts new file mode 100644 index 0000000..bd19081 --- /dev/null +++ b/src/app/api/invitations/accept/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { acceptInvitation } from "@/lib/services/invitation-service"; +import { acceptInvitationSchema } from "@/lib/validators/invitation"; + +// POST — accept an invitation +export async function POST(req: NextRequest) { + const { session, error } = await requireAuth(); + if (error) return error; + try { + const body = await req.json(); + const parsed = acceptInvitationSchema.safeParse(body); + if (!parsed.success) return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + const result = await acceptInvitation(parsed.data.token, session.user.id); + return NextResponse.json(result); + } catch (e: any) { + if (e.message?.includes("not found") || e.message?.includes("expired") || e.message?.includes("already accepted")) { + return badRequest(e.message); + } + return serverError(e); + } +} diff --git a/src/app/api/my-work/route.ts b/src/app/api/my-work/route.ts index d1ba49f..592af72 100644 --- a/src/app/api/my-work/route.ts +++ b/src/app/api/my-work/route.ts @@ -1,14 +1,15 @@ import { NextResponse } from "next/server"; -import { getAuthSession, serverError } from "@/lib/api-utils"; +import { serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; import { getMyWork } from "@/lib/services/assignment-service"; // GET /api/my-work — get current user's assignments export async function GET() { - const { session, error } = await getAuthSession(); + const { session, error } = await requireAuth("PROJECT_VIEW"); if (error) return error; try { - const assignments = await getMyWork(session!.user.id); + const assignments = await getMyWork(session.user.id); return NextResponse.json(assignments); } catch (e) { return serverError(e); diff --git a/src/app/api/notifications/[notificationId]/route.ts b/src/app/api/notifications/[notificationId]/route.ts index 0209ade..7665855 100644 --- a/src/app/api/notifications/[notificationId]/route.ts +++ b/src/app/api/notifications/[notificationId]/route.ts @@ -1,17 +1,18 @@ import { NextResponse } from "next/server"; -import { getAuthSession, serverError } from "@/lib/api-utils"; +import { serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; import { markAsRead } from "@/lib/services/notification-service"; type Params = { params: Promise<{ notificationId: string }> }; // PATCH /api/notifications/:notificationId — mark as read export async function PATCH(_request: Request, { params }: Params) { - const { session, error } = await getAuthSession(); + const { session, error } = await requireAuth(); if (error) return error; try { const { notificationId } = await params; - await markAsRead(notificationId, session!.user!.id!); + await markAsRead(notificationId, session.user.id); return NextResponse.json({ success: true }); } catch (e) { return serverError(e); diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts index 0091862..d7ba45a 100644 --- a/src/app/api/notifications/route.ts +++ b/src/app/api/notifications/route.ts @@ -1,10 +1,11 @@ import { NextResponse } from "next/server"; -import { getAuthSession, serverError } from "@/lib/api-utils"; +import { serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; import { listNotifications, getUnreadCount, markAllAsRead } from "@/lib/services/notification-service"; // GET /api/notifications?unreadOnly=true export async function GET(request: Request) { - const { session, error } = await getAuthSession(); + const { session, error } = await requireAuth(); if (error) return error; try { @@ -14,11 +15,11 @@ export async function GET(request: Request) { const countOnly = url.searchParams.get("countOnly") === "true"; if (countOnly) { - const count = await getUnreadCount(session!.user!.id!); + const count = await getUnreadCount(session.user.id); return NextResponse.json({ count }); } - const notifications = await listNotifications(session!.user!.id!, { + const notifications = await listNotifications(session.user.id, { limit, unreadOnly, }); @@ -30,11 +31,11 @@ export async function GET(request: Request) { // PATCH /api/notifications — mark all as read export async function PATCH(_request: Request) { - const { session, error } = await getAuthSession(); + const { session, error } = await requireAuth(); if (error) return error; try { - await markAllAsRead(session!.user!.id!); + await markAllAsRead(session.user.id); return NextResponse.json({ success: true }); } catch (e) { return serverError(e); diff --git a/src/app/api/org/fields/[fieldId]/route.ts b/src/app/api/org/fields/[fieldId]/route.ts new file mode 100644 index 0000000..de11e71 --- /dev/null +++ b/src/app/api/org/fields/[fieldId]/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server"; +import { badRequest, notFound, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { updateCustomField, deleteCustomField } from "@/lib/services/custom-field-service"; +import { updateCustomFieldSchema } from "@/lib/validators/custom-field"; + +// PATCH /api/org/fields/:fieldId — update a custom field definition +export async function PATCH( + request: Request, + { params }: { params: Promise<{ fieldId: string }> } +) { + const { session, error } = await requireAuth("FIELD_CUSTOMIZE"); + if (error) return error; + + try { + const { fieldId } = await params; + const body = await request.json(); + const parsed = updateCustomFieldSchema.safeParse(body); + + if (!parsed.success) { + return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + } + + const field = await updateCustomField( + fieldId, + parsed.data, + session.user.organizationId + ); + return NextResponse.json(field); + } catch (e: any) { + if (e?.code === "P2025") return notFound("Custom field not found"); + return serverError(e); + } +} + +// DELETE /api/org/fields/:fieldId — delete a custom field definition +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ fieldId: string }> } +) { + const { session, error } = await requireAuth("FIELD_CUSTOMIZE"); + if (error) return error; + + try { + const { fieldId } = await params; + await deleteCustomField(fieldId, session.user.organizationId); + return NextResponse.json({ ok: true }); + } catch (e: any) { + if (e?.code === "P2025") return notFound("Custom field not found"); + return serverError(e); + } +} diff --git a/src/app/api/org/fields/route.ts b/src/app/api/org/fields/route.ts new file mode 100644 index 0000000..5713b8b --- /dev/null +++ b/src/app/api/org/fields/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from "next/server"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { listCustomFields, createCustomField } from "@/lib/services/custom-field-service"; +import { createCustomFieldSchema } from "@/lib/validators/custom-field"; + +// GET /api/org/fields — list custom field definitions +export async function GET(request: Request) { + const { session, error } = await requireAuth("FIELD_CUSTOMIZE"); + if (error) return error; + + try { + const { searchParams } = new URL(request.url); + const entityType = searchParams.get("entityType") || undefined; + + const fields = await listCustomFields( + session.user.organizationId, + entityType + ); + return NextResponse.json(fields); + } catch (e) { + return serverError(e); + } +} + +// POST /api/org/fields — create a custom field definition +export async function POST(request: Request) { + const { session, error } = await requireAuth("FIELD_CUSTOMIZE"); + if (error) return error; + + try { + const body = await request.json(); + const parsed = createCustomFieldSchema.safeParse(body); + + if (!parsed.success) { + return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + } + + const field = await createCustomField( + parsed.data, + session.user.organizationId + ); + return NextResponse.json(field, { status: 201 }); + } catch (e) { + return serverError(e); + } +} diff --git a/src/app/api/org/invitations/[id]/route.ts b/src/app/api/org/invitations/[id]/route.ts new file mode 100644 index 0000000..2c0f0cb --- /dev/null +++ b/src/app/api/org/invitations/[id]/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; +import { serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { revokeInvitation } from "@/lib/services/invitation-service"; + +// DELETE — revoke an invitation +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { session, error } = await requireAuth("USER_MANAGE"); + if (error) return error; + try { + const { id } = await params; + await revokeInvitation(id, session.user.organizationId); + return NextResponse.json({ success: true }); + } catch (e) { + return serverError(e); + } +} diff --git a/src/app/api/org/invitations/route.ts b/src/app/api/org/invitations/route.ts new file mode 100644 index 0000000..5bf049b --- /dev/null +++ b/src/app/api/org/invitations/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { listInvitations, createInvitation } from "@/lib/services/invitation-service"; +import { createInvitationSchema } from "@/lib/validators/invitation"; + +// GET — list invitations for the user's org +export async function GET() { + const { session, error } = await requireAuth("USER_MANAGE"); + if (error) return error; + try { + const invitations = await listInvitations(session.user.organizationId); + return NextResponse.json(invitations); + } catch (e) { + return serverError(e); + } +} + +// POST — create an invitation +export async function POST(req: NextRequest) { + const { session, error } = await requireAuth("USER_MANAGE"); + if (error) return error; + try { + const body = await req.json(); + const parsed = createInvitationSchema.safeParse(body); + if (!parsed.success) return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + const invitation = await createInvitation( + parsed.data.email, + parsed.data.role || "ARTIST", + session.user.organizationId, + session.user.id + ); + return NextResponse.json(invitation, { status: 201 }); + } catch (e: any) { + if (e.message?.includes("already a member")) return badRequest(e.message); + return serverError(e); + } +} diff --git a/src/app/api/org/notification-rules/[ruleId]/route.ts b/src/app/api/org/notification-rules/[ruleId]/route.ts new file mode 100644 index 0000000..4cfa56f --- /dev/null +++ b/src/app/api/org/notification-rules/[ruleId]/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { + updateNotificationRule, + deleteNotificationRule, +} from "@/lib/services/notification-rule-service"; +import { updateNotificationRuleSchema } from "@/lib/validators/notification-rule"; + +// PATCH — update a notification rule +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ ruleId: string }> } +) { + const { session, error } = await requireAuth("ORG_SETTINGS"); + if (error) return error; + try { + const { ruleId } = await params; + const body = await req.json(); + const parsed = updateNotificationRuleSchema.safeParse(body); + if (!parsed.success) + return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + const rule = await updateNotificationRule( + ruleId, + parsed.data, + session.user.organizationId + ); + return NextResponse.json(rule); + } catch (e) { + return serverError(e); + } +} + +// DELETE — delete a notification rule +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ ruleId: string }> } +) { + const { session, error } = await requireAuth("ORG_SETTINGS"); + if (error) return error; + try { + const { ruleId } = await params; + await deleteNotificationRule(ruleId, session.user.organizationId); + return NextResponse.json({ success: true }); + } catch (e) { + return serverError(e); + } +} diff --git a/src/app/api/org/notification-rules/route.ts b/src/app/api/org/notification-rules/route.ts new file mode 100644 index 0000000..64ec716 --- /dev/null +++ b/src/app/api/org/notification-rules/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { + listNotificationRules, + createNotificationRule, +} from "@/lib/services/notification-rule-service"; +import { createNotificationRuleSchema } from "@/lib/validators/notification-rule"; + +// GET — list notification rules for the user's org +export async function GET() { + const { session, error } = await requireAuth("ORG_SETTINGS"); + if (error) return error; + try { + const rules = await listNotificationRules(session.user.organizationId); + return NextResponse.json(rules); + } catch (e) { + return serverError(e); + } +} + +// POST — create a notification rule +export async function POST(req: NextRequest) { + const { session, error } = await requireAuth("ORG_SETTINGS"); + if (error) return error; + try { + const body = await req.json(); + const parsed = createNotificationRuleSchema.safeParse(body); + if (!parsed.success) + return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + const rule = await createNotificationRule( + parsed.data, + session.user.organizationId + ); + return NextResponse.json(rule, { status: 201 }); + } catch (e) { + return serverError(e); + } +} diff --git a/src/app/api/org/permissions/route.ts b/src/app/api/org/permissions/route.ts new file mode 100644 index 0000000..5692a1a --- /dev/null +++ b/src/app/api/org/permissions/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { getOrgPermissions, updateRolePermissions } from "@/lib/services/rbac-service"; +import { updatePermissionsSchema } from "@/lib/validators/permissions"; +import type { Role, Permission } from "@/generated/prisma/client"; + +// GET /api/org/permissions — get permission matrix for all roles +export async function GET() { + const { session, error } = await requireAuth("ORG_SETTINGS"); + if (error) return error; + + try { + const permissions = await getOrgPermissions(session.user.organizationId); + return NextResponse.json(permissions); + } catch (e) { + return serverError(e); + } +} + +// PUT /api/org/permissions — update permissions for a role +export async function PUT(request: Request) { + const { session, error } = await requireAuth("ROLE_MANAGE"); + if (error) return error; + + try { + const body = await request.json(); + const parsed = updatePermissionsSchema.safeParse(body); + + if (!parsed.success) { + return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + } + + const result = await updateRolePermissions( + session.user.organizationId, + parsed.data.role as Role, + parsed.data.permissions as Permission[] + ); + + return NextResponse.json(result); + } catch (e) { + return serverError(e); + } +} diff --git a/src/app/api/pipelines/[pipelineId]/dependencies/route.ts b/src/app/api/pipelines/[pipelineId]/dependencies/route.ts new file mode 100644 index 0000000..5039b37 --- /dev/null +++ b/src/app/api/pipelines/[pipelineId]/dependencies/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { addDependency, removeDependency } from "@/lib/services/pipeline-template-service"; +import { addDependencySchema } from "@/lib/validators/pipeline-template"; + +// POST — add a dependency between stages +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ pipelineId: string }> } +) { + const { session, error } = await requireAuth("PIPELINE_MANAGE"); + if (error) return error; + try { + const { pipelineId } = await params; + const body = await req.json(); + const parsed = addDependencySchema.safeParse(body); + if (!parsed.success) return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + const dependency = await addDependency(pipelineId, parsed.data, session.user.organizationId); + return NextResponse.json(dependency, { status: 201 }); + } catch (e) { + return serverError(e); + } +} + +// DELETE — remove a dependency between stages +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ pipelineId: string }> } +) { + const { session, error } = await requireAuth("PIPELINE_MANAGE"); + if (error) return error; + try { + const { pipelineId } = await params; + const body = await req.json(); + const { stageId, prerequisiteId } = body; + if (!stageId || !prerequisiteId) { + return badRequest("stageId and prerequisiteId are required"); + } + await removeDependency(pipelineId, stageId, prerequisiteId, session.user.organizationId); + return NextResponse.json({ success: true }); + } catch (e) { + return serverError(e); + } +} diff --git a/src/app/api/pipelines/[pipelineId]/duplicate/route.ts b/src/app/api/pipelines/[pipelineId]/duplicate/route.ts new file mode 100644 index 0000000..cf8b24c --- /dev/null +++ b/src/app/api/pipelines/[pipelineId]/duplicate/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { duplicatePipelineTemplate } from "@/lib/services/pipeline-template-service"; + +// POST — duplicate a pipeline template +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ pipelineId: string }> } +) { + const { session, error } = await requireAuth("PIPELINE_MANAGE"); + if (error) return error; + try { + const { pipelineId } = await params; + const body = await req.json(); + if (!body.name || typeof body.name !== "string" || body.name.trim().length === 0) { + return badRequest("name is required"); + } + const template = await duplicatePipelineTemplate(pipelineId, body.name.trim(), session.user.organizationId); + return NextResponse.json(template, { status: 201 }); + } catch (e) { + return serverError(e); + } +} diff --git a/src/app/api/pipelines/[pipelineId]/route.ts b/src/app/api/pipelines/[pipelineId]/route.ts new file mode 100644 index 0000000..5addc50 --- /dev/null +++ b/src/app/api/pipelines/[pipelineId]/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from "next/server"; +import { badRequest, notFound, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { + getPipelineTemplate, + updatePipelineTemplate, + archivePipelineTemplate, +} from "@/lib/services/pipeline-template-service"; +import { updatePipelineTemplateSchema } from "@/lib/validators/pipeline-template"; + +// GET — get a single pipeline template +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ pipelineId: string }> } +) { + const { session, error } = await requireAuth("PROJECT_VIEW"); + if (error) return error; + try { + const { pipelineId } = await params; + const template = await getPipelineTemplate(pipelineId, session.user.organizationId); + if (!template) return notFound("Pipeline template not found"); + return NextResponse.json(template); + } catch (e) { + return serverError(e); + } +} + +// PATCH — update pipeline template +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ pipelineId: string }> } +) { + const { session, error } = await requireAuth("PIPELINE_MANAGE"); + if (error) return error; + try { + const { pipelineId } = await params; + const body = await req.json(); + const parsed = updatePipelineTemplateSchema.safeParse(body); + if (!parsed.success) return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + const template = await updatePipelineTemplate(pipelineId, parsed.data, session.user.organizationId); + return NextResponse.json(template); + } catch (e) { + return serverError(e); + } +} + +// DELETE — archive pipeline template +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ pipelineId: string }> } +) { + const { session, error } = await requireAuth("PIPELINE_MANAGE"); + if (error) return error; + try { + const { pipelineId } = await params; + await archivePipelineTemplate(pipelineId, session.user.organizationId); + return NextResponse.json({ success: true }); + } catch (e) { + return serverError(e); + } +} diff --git a/src/app/api/pipelines/[pipelineId]/stages/[stageId]/route.ts b/src/app/api/pipelines/[pipelineId]/stages/[stageId]/route.ts new file mode 100644 index 0000000..30aea48 --- /dev/null +++ b/src/app/api/pipelines/[pipelineId]/stages/[stageId]/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from "next/server"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { updateStage, removeStage } from "@/lib/services/pipeline-template-service"; +import { updateStageDefSchema } from "@/lib/validators/pipeline-template"; + +// PATCH — update a stage definition +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ pipelineId: string; stageId: string }> } +) { + const { session, error } = await requireAuth("PIPELINE_MANAGE"); + if (error) return error; + try { + const { pipelineId, stageId } = await params; + const body = await req.json(); + const parsed = updateStageDefSchema.safeParse(body); + if (!parsed.success) return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + const stage = await updateStage(pipelineId, stageId, parsed.data, session.user.organizationId); + return NextResponse.json(stage); + } catch (e) { + return serverError(e); + } +} + +// DELETE — remove a stage from a pipeline template +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ pipelineId: string; stageId: string }> } +) { + const { session, error } = await requireAuth("PIPELINE_MANAGE"); + if (error) return error; + try { + const { pipelineId, stageId } = await params; + await removeStage(pipelineId, stageId, session.user.organizationId); + return NextResponse.json({ success: true }); + } catch (e) { + return serverError(e); + } +} diff --git a/src/app/api/pipelines/[pipelineId]/stages/reorder/route.ts b/src/app/api/pipelines/[pipelineId]/stages/reorder/route.ts new file mode 100644 index 0000000..e46877a --- /dev/null +++ b/src/app/api/pipelines/[pipelineId]/stages/reorder/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { reorderStages } from "@/lib/services/pipeline-template-service"; +import { reorderStagesSchema } from "@/lib/validators/pipeline-template"; + +// PUT — reorder stages in a pipeline template +export async function PUT( + req: NextRequest, + { params }: { params: Promise<{ pipelineId: string }> } +) { + const { session, error } = await requireAuth("PIPELINE_MANAGE"); + if (error) return error; + try { + const { pipelineId } = await params; + const body = await req.json(); + const parsed = reorderStagesSchema.safeParse(body); + if (!parsed.success) return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + const stages = await reorderStages(pipelineId, parsed.data.stageIds, session.user.organizationId); + return NextResponse.json(stages); + } catch (e) { + return serverError(e); + } +} diff --git a/src/app/api/pipelines/[pipelineId]/stages/route.ts b/src/app/api/pipelines/[pipelineId]/stages/route.ts new file mode 100644 index 0000000..75df71f --- /dev/null +++ b/src/app/api/pipelines/[pipelineId]/stages/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { addStage } from "@/lib/services/pipeline-template-service"; +import { addStageSchema } from "@/lib/validators/pipeline-template"; + +// POST — add a stage to a pipeline template +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ pipelineId: string }> } +) { + const { session, error } = await requireAuth("PIPELINE_MANAGE"); + if (error) return error; + try { + const { pipelineId } = await params; + const body = await req.json(); + const parsed = addStageSchema.safeParse(body); + if (!parsed.success) return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + const stage = await addStage(pipelineId, parsed.data, session.user.organizationId); + return NextResponse.json(stage, { status: 201 }); + } catch (e) { + return serverError(e); + } +} diff --git a/src/app/api/pipelines/[pipelineId]/validate/route.ts b/src/app/api/pipelines/[pipelineId]/validate/route.ts new file mode 100644 index 0000000..79cfd62 --- /dev/null +++ b/src/app/api/pipelines/[pipelineId]/validate/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; +import { serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { validatePipeline } from "@/lib/services/pipeline-template-service"; + +// GET — validate a pipeline template +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ pipelineId: string }> } +) { + const { session, error } = await requireAuth("PROJECT_VIEW"); + if (error) return error; + try { + const { pipelineId } = await params; + const result = await validatePipeline(pipelineId, session.user.organizationId); + return NextResponse.json(result); + } catch (e) { + return serverError(e); + } +} diff --git a/src/app/api/pipelines/route.ts b/src/app/api/pipelines/route.ts new file mode 100644 index 0000000..9d7e7a6 --- /dev/null +++ b/src/app/api/pipelines/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { listPipelineTemplates, createPipelineTemplate } from "@/lib/services/pipeline-template-service"; +import { createPipelineTemplateSchema } from "@/lib/validators/pipeline-template"; + +// GET — list pipeline templates +export async function GET() { + const { session, error } = await requireAuth("PROJECT_VIEW"); + if (error) return error; + try { + const templates = await listPipelineTemplates(session.user.organizationId); + return NextResponse.json(templates); + } catch (e) { + return serverError(e); + } +} + +// POST — create pipeline template +export async function POST(request: Request) { + const { session, error } = await requireAuth("PIPELINE_MANAGE"); + if (error) return error; + try { + const body = await request.json(); + const parsed = createPipelineTemplateSchema.safeParse(body); + if (!parsed.success) return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + const template = await createPipelineTemplate(parsed.data, session.user.organizationId); + return NextResponse.json(template, { status: 201 }); + } catch (e) { + return serverError(e); + } +} diff --git a/src/app/api/projects/[projectId]/deliverables/[deliverableId]/route.ts b/src/app/api/projects/[projectId]/deliverables/[deliverableId]/route.ts index 41f5304..2916660 100644 --- a/src/app/api/projects/[projectId]/deliverables/[deliverableId]/route.ts +++ b/src/app/api/projects/[projectId]/deliverables/[deliverableId]/route.ts @@ -1,10 +1,7 @@ import { NextResponse } from "next/server"; -import { - getAuthSession, - badRequest, - notFound, - serverError, -} from "@/lib/api-utils"; +import { badRequest, notFound, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { assertOrgAccess } from "@/lib/rbac/org-scope"; import { updateDeliverableSchema } from "@/lib/validators/deliverable"; import { getDeliverable, @@ -16,11 +13,12 @@ type Params = { params: Promise<{ projectId: string; deliverableId: string }> }; // GET /api/projects/:projectId/deliverables/:deliverableId export async function GET(_request: Request, { params }: Params) { - const { error } = await getAuthSession(); + const { session, error } = await requireAuth("PROJECT_VIEW"); if (error) return error; try { const { deliverableId } = await params; + await assertOrgAccess("deliverable", deliverableId, session.user.organizationId); const deliverable = await getDeliverable(deliverableId); if (!deliverable) return notFound("Deliverable not found"); @@ -33,11 +31,12 @@ export async function GET(_request: Request, { params }: Params) { // PATCH /api/projects/:projectId/deliverables/:deliverableId export async function PATCH(request: Request, { params }: Params) { - const { error } = await getAuthSession(); + const { session, error } = await requireAuth("DELIVERABLE_UPDATE"); if (error) return error; try { const { deliverableId } = await params; + await assertOrgAccess("deliverable", deliverableId, session.user.organizationId); const body = await request.json(); const parsed = updateDeliverableSchema.safeParse(body); @@ -54,11 +53,12 @@ export async function PATCH(request: Request, { params }: Params) { // DELETE /api/projects/:projectId/deliverables/:deliverableId export async function DELETE(_request: Request, { params }: Params) { - const { error } = await getAuthSession(); + const { session, error } = await requireAuth("DELIVERABLE_DELETE"); if (error) return error; try { const { deliverableId } = await params; + await assertOrgAccess("deliverable", deliverableId, session.user.organizationId); await deleteDeliverable(deliverableId); return NextResponse.json({ success: true }); } catch (e) { diff --git a/src/app/api/projects/[projectId]/deliverables/route.ts b/src/app/api/projects/[projectId]/deliverables/route.ts index 4669c24..5975188 100644 --- a/src/app/api/projects/[projectId]/deliverables/route.ts +++ b/src/app/api/projects/[projectId]/deliverables/route.ts @@ -1,5 +1,7 @@ import { NextResponse } from "next/server"; -import { getAuthSession, badRequest, serverError } from "@/lib/api-utils"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { assertOrgAccess } from "@/lib/rbac/org-scope"; import { createDeliverableSchema } from "@/lib/validators/deliverable"; import { listDeliverables, @@ -10,11 +12,12 @@ type Params = { params: Promise<{ projectId: string }> }; // GET /api/projects/:projectId/deliverables export async function GET(_request: Request, { params }: Params) { - const { error } = await getAuthSession(); + const { session, error } = await requireAuth("PROJECT_VIEW"); if (error) return error; try { const { projectId } = await params; + await assertOrgAccess("project", projectId, session.user.organizationId); const deliverables = await listDeliverables(projectId); return NextResponse.json(deliverables); } catch (e) { @@ -24,11 +27,12 @@ export async function GET(_request: Request, { params }: Params) { // POST /api/projects/:projectId/deliverables export async function POST(request: Request, { params }: Params) { - const { error } = await getAuthSession(); + const { session, error } = await requireAuth("DELIVERABLE_CREATE"); if (error) return error; try { const { projectId } = await params; + await assertOrgAccess("project", projectId, session.user.organizationId); const body = await request.json(); const parsed = createDeliverableSchema.safeParse(body); diff --git a/src/app/api/projects/[projectId]/export/route.ts b/src/app/api/projects/[projectId]/export/route.ts index cd5f6e9..c65c4e4 100644 --- a/src/app/api/projects/[projectId]/export/route.ts +++ b/src/app/api/projects/[projectId]/export/route.ts @@ -1,15 +1,18 @@ -import { getAuthSession, serverError } from "@/lib/api-utils"; +import { serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { assertOrgAccess } from "@/lib/rbac/org-scope"; import { exportProjectToExcel } from "@/lib/services/excel-service"; type Params = { params: Promise<{ projectId: string }> }; // GET /api/projects/:projectId/export — download Excel workbook export async function GET(_request: Request, { params }: Params) { - const { error } = await getAuthSession(); + const { session, error } = await requireAuth("PROJECT_VIEW"); if (error) return error; try { const { projectId } = await params; + await assertOrgAccess("project", projectId, session.user.organizationId); const workbook = await exportProjectToExcel(projectId); const buffer = await workbook.xlsx.writeBuffer(); diff --git a/src/app/api/projects/[projectId]/import/route.ts b/src/app/api/projects/[projectId]/import/route.ts index 7f81dab..831554b 100644 --- a/src/app/api/projects/[projectId]/import/route.ts +++ b/src/app/api/projects/[projectId]/import/route.ts @@ -1,5 +1,7 @@ import { NextResponse } from "next/server"; -import { getAuthSession, badRequest, serverError } from "@/lib/api-utils"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { assertOrgAccess } from "@/lib/rbac/org-scope"; import { parseExcelImport, importDeliverables } from "@/lib/services/excel-service"; type Params = { params: Promise<{ projectId: string }> }; @@ -7,11 +9,12 @@ type Params = { params: Promise<{ projectId: string }> }; // POST /api/projects/:projectId/import — upload + parse Excel // If ?commit=true, also imports the data export async function POST(request: Request, { params }: Params) { - const { error } = await getAuthSession(); + const { session, error } = await requireAuth("DELIVERABLE_CREATE"); if (error) return error; try { const { projectId } = await params; + await assertOrgAccess("project", projectId, session.user.organizationId); const url = new URL(request.url); const commit = url.searchParams.get("commit") === "true"; diff --git a/src/app/api/projects/[projectId]/route.ts b/src/app/api/projects/[projectId]/route.ts index b6fb2d5..4040a18 100644 --- a/src/app/api/projects/[projectId]/route.ts +++ b/src/app/api/projects/[projectId]/route.ts @@ -1,10 +1,7 @@ import { NextResponse } from "next/server"; -import { - getAuthSession, - badRequest, - notFound, - serverError, -} from "@/lib/api-utils"; +import { badRequest, notFound, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { assertOrgAccess } from "@/lib/rbac/org-scope"; import { updateProjectSchema } from "@/lib/validators/project"; import { getProject, @@ -16,12 +13,13 @@ type Params = { params: Promise<{ projectId: string }> }; // GET /api/projects/:projectId export async function GET(_request: Request, { params }: Params) { - const { session, error } = await getAuthSession(); + const { session, error } = await requireAuth("PROJECT_VIEW"); if (error) return error; try { const { projectId } = await params; - const project = await getProject(projectId, session!.user.organizationId!); + await assertOrgAccess("project", projectId, session.user.organizationId); + const project = await getProject(projectId, session.user.organizationId); if (!project) return notFound("Project not found"); @@ -33,11 +31,12 @@ export async function GET(_request: Request, { params }: Params) { // PATCH /api/projects/:projectId export async function PATCH(request: Request, { params }: Params) { - const { session, error } = await getAuthSession(); + const { session, error } = await requireAuth("PROJECT_UPDATE"); if (error) return error; try { const { projectId } = await params; + await assertOrgAccess("project", projectId, session.user.organizationId); const body = await request.json(); const parsed = updateProjectSchema.safeParse(body); @@ -48,7 +47,7 @@ export async function PATCH(request: Request, { params }: Params) { const project = await updateProject( projectId, parsed.data, - session!.user.organizationId! + session.user.organizationId ); return NextResponse.json(project); } catch (e) { @@ -58,12 +57,13 @@ export async function PATCH(request: Request, { params }: Params) { // DELETE /api/projects/:projectId export async function DELETE(_request: Request, { params }: Params) { - const { session, error } = await getAuthSession(); + const { session, error } = await requireAuth("PROJECT_DELETE"); if (error) return error; try { const { projectId } = await params; - await deleteProject(projectId, session!.user.organizationId!); + await assertOrgAccess("project", projectId, session.user.organizationId); + await deleteProject(projectId, session.user.organizationId); return NextResponse.json({ success: true }); } catch (e) { return serverError(e); diff --git a/src/app/api/projects/bulk-import/route.ts b/src/app/api/projects/bulk-import/route.ts index 217a9df..fc1b435 100644 --- a/src/app/api/projects/bulk-import/route.ts +++ b/src/app/api/projects/bulk-import/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; -import { getAuthSession, badRequest, serverError } from "@/lib/api-utils"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; import { parseMasterTrackerImport, importProjectsFromTracker, @@ -10,10 +11,10 @@ import { // Without ?commit=true → returns a preview of what will be imported. // With ?commit=true → creates projects + deliverables in the DB. export async function POST(request: Request) { - const { session, error } = await getAuthSession(); + const { session, error } = await requireAuth("PROJECT_CREATE"); if (error) return error; - const organizationId = session!.user.organizationId; + const organizationId = session.user.organizationId; if (!organizationId) { return badRequest("User has no organization"); } diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts index f99272e..0afc0f8 100644 --- a/src/app/api/projects/route.ts +++ b/src/app/api/projects/route.ts @@ -1,15 +1,16 @@ import { NextResponse } from "next/server"; -import { getAuthSession, badRequest, serverError } from "@/lib/api-utils"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; import { createProjectSchema } from "@/lib/validators/project"; import { listProjects, createProject } from "@/lib/services/project-service"; // GET /api/projects — list all projects for the user's org export async function GET() { - const { session, error } = await getAuthSession(); + const { session, error } = await requireAuth("PROJECT_VIEW"); if (error) return error; try { - const projects = await listProjects(session!.user.organizationId!); + const projects = await listProjects(session.user.organizationId); return NextResponse.json(projects); } catch (e) { return serverError(e); @@ -18,7 +19,7 @@ export async function GET() { // POST /api/projects — create a new project export async function POST(request: Request) { - const { session, error } = await getAuthSession(); + const { session, error } = await requireAuth("PROJECT_CREATE"); if (error) return error; try { @@ -31,7 +32,7 @@ export async function POST(request: Request) { const project = await createProject( parsed.data, - session!.user.organizationId! + session.user.organizationId ); return NextResponse.json(project, { status: 201 }); } catch (e) { diff --git a/src/app/api/reports/weekly/route.ts b/src/app/api/reports/weekly/route.ts index 565471f..696f047 100644 --- a/src/app/api/reports/weekly/route.ts +++ b/src/app/api/reports/weekly/route.ts @@ -1,10 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; -import { getAuthSession, badRequest, serverError } from "@/lib/api-utils"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; import { getWeeklyReport } from "@/lib/services/weekly-report-service"; import { parseISO, isValid } from "date-fns"; export async function GET(request: NextRequest) { - const { session, error } = await getAuthSession(); + const { session, error } = await requireAuth("PROJECT_VIEW"); if (error) return error; try { @@ -22,7 +23,7 @@ export async function GET(request: NextRequest) { } const report = await getWeeklyReport( - session!.user.organizationId!, + session.user.organizationId, weekOf ); diff --git a/src/app/api/search/semantic/route.ts b/src/app/api/search/semantic/route.ts index 7ce12af..8036006 100644 --- a/src/app/api/search/semantic/route.ts +++ b/src/app/api/search/semantic/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; -import { getAuthSession, badRequest, serverError } from "@/lib/api-utils"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; import { semanticSearch, logSearch, @@ -8,7 +9,7 @@ import { checkOllamaHealth } from "@/lib/services/embedding-service"; // POST /api/search/semantic — perform semantic search export async function POST(request: Request) { - const { session, error } = await getAuthSession(); + const { session, error } = await requireAuth("PROJECT_VIEW"); if (error) return error; try { @@ -25,7 +26,7 @@ export async function POST(request: Request) { const results = await semanticSearch( query.trim(), - session!.user.organizationId!, + session.user.organizationId, { limit: typeof limit === "number" ? Math.min(limit, 50) : 10, includeSummary: includeSummary !== false, @@ -33,7 +34,7 @@ export async function POST(request: Request) { ); // Log the search asynchronously (non-blocking) - logSearch(session!.user.id, query.trim(), results.totalResults).catch( + logSearch(session.user.id, query.trim(), results.totalResults).catch( () => {} ); @@ -45,7 +46,7 @@ export async function POST(request: Request) { // GET /api/search/semantic/health — check Ollama availability export async function GET() { - const { error } = await getAuthSession(); + const { error } = await requireAuth("PROJECT_VIEW"); if (error) return error; try { diff --git a/src/app/api/skills/route.ts b/src/app/api/skills/route.ts index 62f883b..2b41128 100644 --- a/src/app/api/skills/route.ts +++ b/src/app/api/skills/route.ts @@ -1,10 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; -import { getAuthSession, badRequest, serverError } from "@/lib/api-utils"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; import { listSkills, createSkill, deleteSkill } from "@/lib/services/skill-service"; // GET /api/skills — list all skills export async function GET() { - const { error } = await getAuthSession(); + const { error } = await requireAuth("PROJECT_VIEW"); if (error) return error; try { @@ -17,7 +18,7 @@ export async function GET() { // POST /api/skills — create a skill export async function POST(request: NextRequest) { - const { error } = await getAuthSession(); + const { error } = await requireAuth("USER_MANAGE"); if (error) return error; try { @@ -40,7 +41,7 @@ export async function POST(request: NextRequest) { // DELETE /api/skills — delete a skill export async function DELETE(request: NextRequest) { - const { error } = await getAuthSession(); + const { error } = await requireAuth("USER_MANAGE"); if (error) return error; try { diff --git a/src/app/api/stages/[stageId]/assignments/route.ts b/src/app/api/stages/[stageId]/assignments/route.ts index ffcee82..283b529 100644 --- a/src/app/api/stages/[stageId]/assignments/route.ts +++ b/src/app/api/stages/[stageId]/assignments/route.ts @@ -1,5 +1,7 @@ import { NextResponse } from "next/server"; -import { getAuthSession, badRequest, serverError } from "@/lib/api-utils"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { assertOrgAccess } from "@/lib/rbac/org-scope"; import { assignUserSchema } from "@/lib/validators/assignment"; import { assignUserToStage, @@ -10,11 +12,12 @@ type Params = { params: Promise<{ stageId: string }> }; // POST /api/stages/:stageId/assignments — assign user export async function POST(request: Request, { params }: Params) { - const { error } = await getAuthSession(); + const { session, error } = await requireAuth("STAGE_ASSIGN"); if (error) return error; try { const { stageId } = await params; + await assertOrgAccess("deliverableStage", stageId, session.user.organizationId); const body = await request.json(); const parsed = assignUserSchema.safeParse(body); @@ -35,11 +38,12 @@ export async function POST(request: Request, { params }: Params) { // DELETE /api/stages/:stageId/assignments — unassign user export async function DELETE(request: Request, { params }: Params) { - const { error } = await getAuthSession(); + const { session, error } = await requireAuth("STAGE_ASSIGN"); if (error) return error; try { const { stageId } = await params; + await assertOrgAccess("deliverableStage", stageId, session.user.organizationId); const { userId } = await request.json(); if (!userId) return badRequest("userId is required"); diff --git a/src/app/api/stages/[stageId]/comments/[commentId]/route.ts b/src/app/api/stages/[stageId]/comments/[commentId]/route.ts index c88de06..06ce187 100644 --- a/src/app/api/stages/[stageId]/comments/[commentId]/route.ts +++ b/src/app/api/stages/[stageId]/comments/[commentId]/route.ts @@ -1,17 +1,20 @@ import { NextResponse } from "next/server"; -import { getAuthSession, serverError } from "@/lib/api-utils"; +import { serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { assertOrgAccess } from "@/lib/rbac/org-scope"; import { deleteComment } from "@/lib/services/comment-service"; type Params = { params: Promise<{ stageId: string; commentId: string }> }; // DELETE /api/stages/:stageId/comments/:commentId export async function DELETE(_request: Request, { params }: Params) { - const { session, error } = await getAuthSession(); + const { session, error } = await requireAuth("COMMENT_CREATE"); if (error) return error; try { - const { commentId } = await params; - await deleteComment(commentId, session!.user!.id!); + const { stageId, commentId } = await params; + await assertOrgAccess("deliverableStage", stageId, session.user.organizationId); + await deleteComment(commentId, session.user.id); return NextResponse.json({ success: true }); } catch (e) { return serverError(e); diff --git a/src/app/api/stages/[stageId]/comments/route.ts b/src/app/api/stages/[stageId]/comments/route.ts index 542be50..a0f5f55 100644 --- a/src/app/api/stages/[stageId]/comments/route.ts +++ b/src/app/api/stages/[stageId]/comments/route.ts @@ -1,5 +1,7 @@ import { NextResponse } from "next/server"; -import { getAuthSession, badRequest, serverError } from "@/lib/api-utils"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { assertOrgAccess } from "@/lib/rbac/org-scope"; import { createCommentSchema } from "@/lib/validators/comment"; import { createComment, listComments } from "@/lib/services/comment-service"; @@ -7,11 +9,12 @@ type Params = { params: Promise<{ stageId: string }> }; // GET /api/stages/:stageId/comments export async function GET(_request: Request, { params }: Params) { - const { error } = await getAuthSession(); + const { session, error } = await requireAuth("PROJECT_VIEW"); if (error) return error; try { const { stageId } = await params; + await assertOrgAccess("deliverableStage", stageId, session.user.organizationId); const comments = await listComments(stageId); return NextResponse.json(comments); } catch (e) { @@ -21,11 +24,12 @@ export async function GET(_request: Request, { params }: Params) { // POST /api/stages/:stageId/comments — create comment or reply export async function POST(request: Request, { params }: Params) { - const { session, error } = await getAuthSession(); + const { session, error } = await requireAuth("COMMENT_CREATE"); if (error) return error; try { const { stageId } = await params; + await assertOrgAccess("deliverableStage", stageId, session.user.organizationId); const body = await request.json(); const parsed = createCommentSchema.safeParse(body); @@ -33,7 +37,7 @@ export async function POST(request: Request, { params }: Params) { return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); } - const comment = await createComment(stageId, session!.user!.id!, parsed.data); + const comment = await createComment(stageId, session.user.id, parsed.data); return NextResponse.json(comment, { status: 201 }); } catch (e) { return serverError(e); diff --git a/src/app/api/stages/[stageId]/revisions/[revisionId]/route.ts b/src/app/api/stages/[stageId]/revisions/[revisionId]/route.ts index 2c4ea7b..033ff46 100644 --- a/src/app/api/stages/[stageId]/revisions/[revisionId]/route.ts +++ b/src/app/api/stages/[stageId]/revisions/[revisionId]/route.ts @@ -1,5 +1,7 @@ import { NextResponse } from "next/server"; -import { getAuthSession, badRequest, notFound, serverError } from "@/lib/api-utils"; +import { badRequest, notFound, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { assertOrgAccess } from "@/lib/rbac/org-scope"; import { updateRevisionSchema } from "@/lib/validators/revision"; import { getRevision, updateRevision } from "@/lib/services/revision-service"; @@ -7,11 +9,12 @@ type Params = { params: Promise<{ stageId: string; revisionId: string }> }; // GET /api/stages/:stageId/revisions/:revisionId export async function GET(_request: Request, { params }: Params) { - const { error } = await getAuthSession(); + const { session, error } = await requireAuth("PROJECT_VIEW"); if (error) return error; try { - const { revisionId } = await params; + const { stageId, revisionId } = await params; + await assertOrgAccess("deliverableStage", stageId, session.user.organizationId); const revision = await getRevision(revisionId); if (!revision) return notFound("Revision not found"); return NextResponse.json(revision); @@ -22,11 +25,12 @@ export async function GET(_request: Request, { params }: Params) { // PATCH /api/stages/:stageId/revisions/:revisionId — update status/feedback export async function PATCH(request: Request, { params }: Params) { - const { error } = await getAuthSession(); + const { session, error } = await requireAuth("REVISION_REVIEW"); if (error) return error; try { - const { revisionId } = await params; + const { stageId, revisionId } = await params; + await assertOrgAccess("deliverableStage", stageId, session.user.organizationId); const body = await request.json(); const parsed = updateRevisionSchema.safeParse(body); diff --git a/src/app/api/stages/[stageId]/revisions/route.ts b/src/app/api/stages/[stageId]/revisions/route.ts index b6ed99d..7cc57af 100644 --- a/src/app/api/stages/[stageId]/revisions/route.ts +++ b/src/app/api/stages/[stageId]/revisions/route.ts @@ -1,5 +1,7 @@ import { NextResponse } from "next/server"; -import { getAuthSession, badRequest, serverError } from "@/lib/api-utils"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { assertOrgAccess } from "@/lib/rbac/org-scope"; import { createRevisionSchema } from "@/lib/validators/revision"; import { createRevision, listRevisions } from "@/lib/services/revision-service"; @@ -7,11 +9,12 @@ type Params = { params: Promise<{ stageId: string }> }; // GET /api/stages/:stageId/revisions export async function GET(_request: Request, { params }: Params) { - const { error } = await getAuthSession(); + const { session, error } = await requireAuth("PROJECT_VIEW"); if (error) return error; try { const { stageId } = await params; + await assertOrgAccess("deliverableStage", stageId, session.user.organizationId); const revisions = await listRevisions(stageId); return NextResponse.json(revisions); } catch (e) { @@ -21,11 +24,12 @@ export async function GET(_request: Request, { params }: Params) { // POST /api/stages/:stageId/revisions — create new revision round export async function POST(request: Request, { params }: Params) { - const { error } = await getAuthSession(); + const { session, error } = await requireAuth("REVISION_CREATE"); if (error) return error; try { const { stageId } = await params; + await assertOrgAccess("deliverableStage", stageId, session.user.organizationId); const body = await request.json(); const parsed = createRevisionSchema.safeParse(body); diff --git a/src/app/api/stages/[stageId]/route.ts b/src/app/api/stages/[stageId]/route.ts index bed5d48..40e50bf 100644 --- a/src/app/api/stages/[stageId]/route.ts +++ b/src/app/api/stages/[stageId]/route.ts @@ -1,5 +1,7 @@ import { NextResponse } from "next/server"; -import { getAuthSession, badRequest, serverError } from "@/lib/api-utils"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { assertOrgAccess } from "@/lib/rbac/org-scope"; import { updateStageStatus } from "@/lib/services/stage-service"; import { overrideStageDates, @@ -12,11 +14,12 @@ type Params = { params: Promise<{ stageId: string }> }; // PATCH /api/stages/:stageId — update stage status and/or subStatus export async function PATCH(request: Request, { params }: Params) { - const { error } = await getAuthSession(); + const { session, error } = await requireAuth("STAGE_UPDATE_STATUS"); if (error) return error; try { const { stageId } = await params; + await assertOrgAccess("deliverableStage", stageId, session.user.organizationId); const body = await request.json(); // Handle date override requests diff --git a/src/app/api/stages/[stageId]/suggestions/route.ts b/src/app/api/stages/[stageId]/suggestions/route.ts index 4da1a95..eb339ef 100644 --- a/src/app/api/stages/[stageId]/suggestions/route.ts +++ b/src/app/api/stages/[stageId]/suggestions/route.ts @@ -1,5 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; -import { getAuthSession, serverError, notFound } from "@/lib/api-utils"; +import { serverError, notFound } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; +import { assertOrgAccess } from "@/lib/rbac/org-scope"; import { getSuggestedArtists } from "@/lib/services/skill-service"; import { prisma } from "@/lib/prisma"; @@ -7,11 +9,12 @@ type Params = { params: Promise<{ stageId: string }> }; // GET /api/stages/:stageId/suggestions — get suggested artists for a stage export async function GET(_request: NextRequest, { params }: Params) { - const { session, error } = await getAuthSession(); + const { session, error } = await requireAuth("STAGE_ASSIGN"); if (error) return error; try { const { stageId } = await params; + await assertOrgAccess("deliverableStage", stageId, session.user.organizationId); // Get the stage to find its template const stage = await prisma.deliverableStage.findUnique({ @@ -23,7 +26,7 @@ export async function GET(_request: NextRequest, { params }: Params) { const suggestions = await getSuggestedArtists( stage.templateId, - session!.user.organizationId! + session.user.organizationId ); return NextResponse.json(suggestions); diff --git a/src/app/api/timeline/route.ts b/src/app/api/timeline/route.ts index 11a64b7..e7ea749 100644 --- a/src/app/api/timeline/route.ts +++ b/src/app/api/timeline/route.ts @@ -1,10 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; -import { getAuthSession, serverError } from "@/lib/api-utils"; +import { serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; import { prisma } from "@/lib/prisma"; // GET /api/timeline?status=ACTIVE&priority=&projectIds= export async function GET(request: NextRequest) { - const { session, error } = await getAuthSession(); + const { session, error } = await requireAuth("PROJECT_VIEW"); if (error) return error; try { @@ -14,7 +15,7 @@ export async function GET(request: NextRequest) { const projectIdsParam = searchParams.get("projectIds") || undefined; const projectIds = projectIdsParam ? projectIdsParam.split(",") : undefined; - const organizationId = session!.user.organizationId!; + const organizationId = session.user.organizationId; // Build project filter const projectWhere: any = { organizationId }; diff --git a/src/app/api/users/[userId]/route.ts b/src/app/api/users/[userId]/route.ts index 41c141a..f6eadf8 100644 --- a/src/app/api/users/[userId]/route.ts +++ b/src/app/api/users/[userId]/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; -import { getAuthSession, badRequest, serverError } from "@/lib/api-utils"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; import { updateUserRole } from "@/lib/services/user-service"; import { z } from "zod/v4"; @@ -11,14 +12,9 @@ type Params = { params: Promise<{ userId: string }> }; // PATCH /api/users/:userId — update user role export async function PATCH(request: Request, { params }: Params) { - const { session, error } = await getAuthSession(); + const { session, error } = await requireAuth("USER_MANAGE"); if (error) return error; - // Only ADMIN and PRODUCER can change roles - if (session!.user.role !== "ADMIN" && session!.user.role !== "PRODUCER") { - return badRequest("Insufficient permissions"); - } - try { const { userId } = await params; const body = await request.json(); diff --git a/src/app/api/users/[userId]/skills/route.ts b/src/app/api/users/[userId]/skills/route.ts index d8e45f6..81ad66d 100644 --- a/src/app/api/users/[userId]/skills/route.ts +++ b/src/app/api/users/[userId]/skills/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { getAuthSession, badRequest, serverError } from "@/lib/api-utils"; +import { badRequest, serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; import { getUserSkills, setUserSkill, @@ -10,7 +11,7 @@ type Params = { params: Promise<{ userId: string }> }; // GET /api/users/:userId/skills export async function GET(_request: NextRequest, { params }: Params) { - const { error } = await getAuthSession(); + const { error } = await requireAuth("PROJECT_VIEW"); if (error) return error; try { @@ -24,7 +25,7 @@ export async function GET(_request: NextRequest, { params }: Params) { // POST /api/users/:userId/skills — add/update a skill for a user export async function POST(request: NextRequest, { params }: Params) { - const { error } = await getAuthSession(); + const { error } = await requireAuth("USER_MANAGE"); if (error) return error; try { @@ -43,7 +44,7 @@ export async function POST(request: NextRequest, { params }: Params) { // DELETE /api/users/:userId/skills — remove a skill from a user export async function DELETE(request: NextRequest, { params }: Params) { - const { error } = await getAuthSession(); + const { error } = await requireAuth("USER_MANAGE"); if (error) return error; try { diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts index 04609de..3a126ad 100644 --- a/src/app/api/users/route.ts +++ b/src/app/api/users/route.ts @@ -1,14 +1,15 @@ import { NextResponse } from "next/server"; -import { getAuthSession, serverError } from "@/lib/api-utils"; +import { serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; import { listUsers } from "@/lib/services/user-service"; // GET /api/users — list org users export async function GET() { - const { session, error } = await getAuthSession(); + const { session, error } = await requireAuth("PROJECT_VIEW"); if (error) return error; try { - const users = await listUsers(session!.user.organizationId!); + const users = await listUsers(session.user.organizationId); return NextResponse.json(users); } catch (e) { return serverError(e); diff --git a/src/app/api/workload/route.ts b/src/app/api/workload/route.ts index 5a08339..3f24465 100644 --- a/src/app/api/workload/route.ts +++ b/src/app/api/workload/route.ts @@ -1,10 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; -import { getAuthSession, serverError } from "@/lib/api-utils"; +import { serverError } from "@/lib/api-utils"; +import { requireAuth } from "@/lib/rbac/require-auth"; import { getWorkloadData, updateUserCapacity } from "@/lib/services/workload-service"; // GET /api/workload?numWeeks=8&projectId=xxx&stageType=xxx export async function GET(request: NextRequest) { - const { session, error } = await getAuthSession(); + const { session, error } = await requireAuth("PROJECT_VIEW"); if (error) return error; try { @@ -13,7 +14,7 @@ export async function GET(request: NextRequest) { const projectId = searchParams.get("projectId") || undefined; const stageType = searchParams.get("stageType") || undefined; - const data = await getWorkloadData(session!.user.organizationId!, { + const data = await getWorkloadData(session.user.organizationId, { numWeeks: Math.min(numWeeks, 26), // cap at 26 weeks projectId, stageType, @@ -27,7 +28,7 @@ export async function GET(request: NextRequest) { // PATCH /api/workload — update user capacity export async function PATCH(request: NextRequest) { - const { session, error } = await getAuthSession(); + const { session, error } = await requireAuth("USER_MANAGE"); if (error) return error; try { diff --git a/src/components/pipeline-builder/dependency-edge.tsx b/src/components/pipeline-builder/dependency-edge.tsx new file mode 100644 index 0000000..d68fae5 --- /dev/null +++ b/src/components/pipeline-builder/dependency-edge.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useState } from "react"; +import { + BaseEdge, + EdgeLabelRenderer, + getBezierPath, + type EdgeProps, +} from "@xyflow/react"; +import { X } from "lucide-react"; + +interface DependencyEdgeData { + onDelete?: () => void; + [key: string]: unknown; +} + +export function DependencyEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + style, + data, + markerEnd, +}: EdgeProps) { + const [hovered, setHovered] = useState(false); + + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + + const edgeData = data as DependencyEdgeData | undefined; + + return ( + <> + {/* Invisible wider path for hover detection */} + setHovered(true)} + onMouseLeave={() => setHovered(false)} + /> + + {hovered && edgeData?.onDelete && ( + + + + )} + + ); +} diff --git a/src/components/pipeline-builder/pipeline-graph.tsx b/src/components/pipeline-builder/pipeline-graph.tsx new file mode 100644 index 0000000..20d1879 --- /dev/null +++ b/src/components/pipeline-builder/pipeline-graph.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { useCallback, useMemo } from "react"; +import { + ReactFlow, + Background, + Controls, + useNodesState, + useEdgesState, + type Node, + type Edge, + type Connection, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; + +import { StageNode } from "./stage-node"; +import { DependencyEdge } from "./dependency-edge"; + +interface PipelineGraphProps { + stages: Array<{ + id: string; + name: string; + slug: string; + order: number; + isCriticalGate: boolean; + isOptional: boolean; + color: string | null; + dependsOn: Array<{ prerequisiteId: string }>; + }>; + onConnect: (stageId: string, prerequisiteId: string) => void; + onDeleteEdge: (stageId: string, prerequisiteId: string) => void; + onSelectStage: (id: string) => void; +} + +const nodeTypes = { stage: StageNode }; +const edgeTypes = { dependency: DependencyEdge }; + +function buildLayout( + stages: PipelineGraphProps["stages"] +): { nodes: Node[]; edges: Edge[] } { + const sorted = [...stages].sort((a, b) => a.order - b.order); + + // Group stages by order for horizontal distribution + const orderGroups = new Map(); + for (const stage of sorted) { + const group = orderGroups.get(stage.order) ?? []; + group.push(stage); + orderGroups.set(stage.order, group); + } + + const nodes: Node[] = []; + const NODE_WIDTH = 160; + const NODE_H_GAP = 40; + const NODE_V_GAP = 100; + + let row = 0; + const orderKeys = [...orderGroups.keys()].sort((a, b) => a - b); + + for (const orderKey of orderKeys) { + const group = orderGroups.get(orderKey)!; + const totalWidth = + group.length * NODE_WIDTH + (group.length - 1) * NODE_H_GAP; + const startX = -totalWidth / 2 + NODE_WIDTH / 2; + + group.forEach((stage, colIndex) => { + nodes.push({ + id: stage.id, + type: "stage", + position: { + x: startX + colIndex * (NODE_WIDTH + NODE_H_GAP), + y: row * NODE_V_GAP, + }, + data: { + name: stage.name, + isCriticalGate: stage.isCriticalGate, + isOptional: stage.isOptional, + color: stage.color, + }, + }); + }); + + row++; + } + + const edges: Edge[] = []; + for (const stage of sorted) { + for (const dep of stage.dependsOn) { + edges.push({ + id: `${dep.prerequisiteId}->${stage.id}`, + source: dep.prerequisiteId, + target: stage.id, + type: "dependency", + data: { + stageId: stage.id, + prerequisiteId: dep.prerequisiteId, + }, + }); + } + } + + return { nodes, edges }; +} + +export function PipelineGraph({ + stages, + onConnect, + onDeleteEdge, + onSelectStage, +}: PipelineGraphProps) { + const { nodes: initialNodes, edges: initialEdges } = useMemo( + () => buildLayout(stages), + [stages] + ); + + const edgesWithHandlers = useMemo( + () => + initialEdges.map((edge) => ({ + ...edge, + data: { + ...edge.data, + onDelete: () => + onDeleteEdge( + edge.data?.stageId as string, + edge.data?.prerequisiteId as string + ), + }, + })), + [initialEdges, onDeleteEdge] + ); + + const [nodes, , onNodesChange] = useNodesState(initialNodes); + const [edges, , onEdgesChange] = useEdgesState(edgesWithHandlers); + + const handleConnect = useCallback( + (connection: Connection) => { + if (connection.source && connection.target) { + onConnect(connection.target, connection.source); + } + }, + [onConnect] + ); + + const handleNodeClick = useCallback( + (_: React.MouseEvent, node: Node) => { + onSelectStage(node.id); + }, + [onSelectStage] + ); + + return ( +
+ + + + +
+ ); +} diff --git a/src/components/pipeline-builder/pipeline-stage-list.tsx b/src/components/pipeline-builder/pipeline-stage-list.tsx new file mode 100644 index 0000000..3a216a5 --- /dev/null +++ b/src/components/pipeline-builder/pipeline-stage-list.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { ArrowUp, ArrowDown, Plus, Shield, Clock } from "lucide-react"; + +interface PipelineStageListProps { + stages: Array<{ + id: string; + name: string; + slug: string; + order: number; + isCriticalGate: boolean; + isOptional: boolean; + color: string | null; + estimatedDays: number | null; + }>; + selectedStageId: string | null; + onSelect: (id: string) => void; + onReorder: (stageIds: string[]) => void; + onAddStage: () => void; +} + +export function PipelineStageList({ + stages, + selectedStageId, + onSelect, + onReorder, + onAddStage, +}: PipelineStageListProps) { + const sorted = [...stages].sort((a, b) => a.order - b.order); + + function moveStage(index: number, direction: "up" | "down") { + const ids = sorted.map((s) => s.id); + const targetIndex = direction === "up" ? index - 1 : index + 1; + if (targetIndex < 0 || targetIndex >= ids.length) return; + [ids[index], ids[targetIndex]] = [ids[targetIndex], ids[index]]; + onReorder(ids); + } + + return ( +
+

+ Pipeline Stages +

+ +
+ {sorted.map((stage, index) => ( + + +
+ + ))} +
+ + + + ); +} diff --git a/src/components/pipeline-builder/pipeline-validation-banner.tsx b/src/components/pipeline-builder/pipeline-validation-banner.tsx new file mode 100644 index 0000000..efb948a --- /dev/null +++ b/src/components/pipeline-builder/pipeline-validation-banner.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { AlertCircle, AlertTriangle } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface PipelineValidationBannerProps { + errors: string[]; + warnings: string[]; +} + +export function PipelineValidationBanner({ + errors, + warnings, +}: PipelineValidationBannerProps) { + if (errors.length === 0 && warnings.length === 0) return null; + + return ( +
+ {errors.length > 0 && ( +
+ +
+ + Errors + +
    + {errors.map((error, i) => ( +
  • {error}
  • + ))} +
+
+
+ )} + + {warnings.length > 0 && ( +
+ +
+ + Warnings + +
    + {warnings.map((warning, i) => ( +
  • {warning}
  • + ))} +
+
+
+ )} +
+ ); +} diff --git a/src/components/pipeline-builder/stage-edit-sheet.tsx b/src/components/pipeline-builder/stage-edit-sheet.tsx new file mode 100644 index 0000000..6e42af8 --- /dev/null +++ b/src/components/pipeline-builder/stage-edit-sheet.tsx @@ -0,0 +1,275 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetFooter, +} from "@/components/ui/sheet"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Separator } from "@/components/ui/separator"; +import { Save, Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +interface StageEditSheetProps { + stage: { + id: string; + name: string; + slug: string; + description: string | null; + isCriticalGate: boolean; + isOptional: boolean; + estimatedDays: number | null; + color: string | null; + } | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onSave: (data: { + name?: string; + slug?: string; + description?: string | null; + isCriticalGate?: boolean; + isOptional?: boolean; + estimatedDays?: number | null; + color?: string | null; + }) => void; + onDelete: () => void; +} + +function slugify(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); +} + +export function StageEditSheet({ + stage, + open, + onOpenChange, + onSave, + onDelete, +}: StageEditSheetProps) { + const [name, setName] = useState(""); + const [slug, setSlug] = useState(""); + const [description, setDescription] = useState(""); + const [isCriticalGate, setIsCriticalGate] = useState(false); + const [isOptional, setIsOptional] = useState(false); + const [estimatedDays, setEstimatedDays] = useState(""); + const [color, setColor] = useState(""); + const [autoSlug, setAutoSlug] = useState(true); + + // Sync form state when stage changes + useEffect(() => { + if (stage) { + setName(stage.name); + setSlug(stage.slug); + setDescription(stage.description ?? ""); + setIsCriticalGate(stage.isCriticalGate); + setIsOptional(stage.isOptional); + setEstimatedDays(stage.estimatedDays != null ? String(stage.estimatedDays) : ""); + setColor(stage.color ?? ""); + setAutoSlug(false); + } + }, [stage]); + + function handleNameChange(value: string) { + setName(value); + if (autoSlug) { + setSlug(slugify(value)); + } + } + + function handleSlugChange(value: string) { + setAutoSlug(false); + setSlug(slugify(value)); + } + + function handleSave() { + if (!name.trim()) { + toast.error("Stage name is required"); + return; + } + if (!slug.trim()) { + toast.error("Stage slug is required"); + return; + } + + onSave({ + name: name.trim(), + slug: slug.trim(), + description: description.trim() || null, + isCriticalGate, + isOptional, + estimatedDays: estimatedDays ? Number(estimatedDays) : null, + color: color.trim() || null, + }); + } + + function handleDelete() { + onDelete(); + } + + if (!stage) return null; + + return ( + + + + Edit Stage + + +
+ {/* Name */} +
+ + handleNameChange(e.target.value)} + placeholder="e.g. Model Prep" + /> +
+ + {/* Slug */} +
+ + handleSlugChange(e.target.value)} + placeholder="e.g. model-prep" + className="font-mono text-xs" + /> +
+ + {/* Description */} +
+ +