feat: add pipeline stage resolver and organization access control

- Implemented `stage-resolver.ts` to unify old and new pipeline stage definitions.
- Created `org-scope.ts` for organization access verification and scoping queries.
- Added role-based permissions management in `permissions.ts` and `rbac-service.ts`.
- Introduced invitation management in `invitation-service.ts` with validation schemas.
- Developed custom field and notification rule services with respective validators.
- Established pipeline template CRUD operations in `pipeline-template-service.ts`.
- Added Zustand store for managing pipeline builder state in `pipeline-builder-store.ts`.
This commit is contained in:
Leivur R. Djurhuus 2026-03-14 22:43:43 -05:00
parent 9d0677419d
commit 40028b7ced
89 changed files with 5800 additions and 170 deletions

167
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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 {

View file

@ -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<string, string[]> = {
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!");
}

View file

@ -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();
});

View file

@ -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<string, string>(); // 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();
});

View file

@ -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<string>("TEXT");
const [newIsRequired, setNewIsRequired] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [editType, setEditType] = useState("");
const [editRequired, setEditRequired] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(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 (
<Card>
<CardHeader>
<CardTitle className="label-upper text-xs tracking-wider">
{label}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Add field form */}
<div className="flex items-center gap-2">
<Input
placeholder="Field name..."
value={newFieldName}
onChange={(e) => setNewFieldName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
className="h-8 text-sm"
/>
<Select value={newFieldType} onValueChange={setNewFieldType}>
<SelectTrigger className="h-8 w-[120px] text-sm shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_TYPES.map((ft) => (
<SelectItem key={ft.value} value={ft.value}>
{ft.label}
</SelectItem>
))}
</SelectContent>
</Select>
<label className="flex shrink-0 items-center gap-1.5 text-xs text-[var(--muted-foreground)]">
<Checkbox
checked={newIsRequired}
onCheckedChange={(v) => setNewIsRequired(v === true)}
className="h-3.5 w-3.5"
/>
Required
</label>
<Button
size="sm"
className="h-8 shrink-0"
onClick={handleCreate}
disabled={!newFieldName.trim() || createField.isPending}
>
<Plus className="mr-1 h-3 w-3" />
Add
</Button>
</div>
{/* Field list */}
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : (fields as any[])?.length === 0 ? (
<p className="py-8 text-center text-sm text-[var(--muted-foreground)]">
No {entityType.toLowerCase()} fields defined yet. Add your first field above.
</p>
) : (
<div className="space-y-1.5">
{(fields as any[])?.map((field: any) => (
<div
key={field.id}
className="flex items-center justify-between rounded-lg border px-3 py-2 transition-colors hover:bg-[var(--muted)]/50"
>
{editingId === field.id ? (
/* Edit mode */
<div className="flex flex-1 items-center gap-2">
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSaveEdit()}
className="h-7 text-sm"
autoFocus
/>
<Select value={editType} onValueChange={setEditType}>
<SelectTrigger className="h-7 w-[110px] text-xs shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_TYPES.map((ft) => (
<SelectItem key={ft.value} value={ft.value}>
{ft.label}
</SelectItem>
))}
</SelectContent>
</Select>
<label className="flex shrink-0 items-center gap-1 text-[10px] text-[var(--muted-foreground)]">
<Checkbox
checked={editRequired}
onCheckedChange={(v) => setEditRequired(v === true)}
className="h-3 w-3"
/>
Req
</label>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-green-600 hover:text-green-700"
onClick={handleSaveEdit}
disabled={updateField.isPending}
>
<Check className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-[var(--muted-foreground)]"
onClick={() => setEditingId(null)}
>
<X className="h-3 w-3" />
</Button>
</div>
) : (
/* Display mode */
<>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{field.fieldName}</span>
<Badge
variant="secondary"
className={cn("text-[9px] h-4 px-1.5", getFieldTypeStyle(field.fieldType))}
>
{getFieldTypeLabel(field.fieldType)}
</Badge>
{field.isRequired && (
<Badge
variant="outline"
className="text-[9px] h-4 px-1 text-red-500 border-red-200 dark:border-red-800"
>
Required
</Badge>
)}
<span className="text-[10px] text-[var(--muted-foreground)]">
#{field.order}
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
onClick={() => handleStartEdit(field)}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-[var(--muted-foreground)] hover:text-red-500"
onClick={() => setDeleteConfirm(field.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</>
)}
</div>
))}
</div>
)}
</CardContent>
{/* Delete confirmation dialog */}
<Dialog open={!!deleteConfirm} onOpenChange={(open) => !open && setDeleteConfirm(null)}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Delete Custom Field</DialogTitle>
</DialogHeader>
<p className="text-sm text-[var(--muted-foreground)]">
This will permanently remove this field definition. Any existing data stored in this field will be lost. This action cannot be undone.
</p>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirm(null)}>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => deleteConfirm && handleDelete(deleteConfirm)}
disabled={deleteField.isPending}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}
export default function CustomFieldsSettingsPage() {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Columns className="h-6 w-6 text-[var(--primary)]" />
<div>
<h1 className="font-heading text-2xl font-bold">Custom Fields</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Add custom metadata fields to projects and deliverables
</p>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<FieldSection entityType="PROJECT" label="Project Fields" />
<FieldSection entityType="DELIVERABLE" label="Deliverable Fields" />
</div>
</div>
);
}

View file

@ -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<string | null>(null);
// Form state
const [formName, setFormName] = useState("");
const [formEvent, setFormEvent] = useState("");
const [formChannels, setFormChannels] = useState<string[]>(["IN_APP"]);
const [formRoles, setFormRoles] = useState<string[]>([]);
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 (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Bell className="h-6 w-6 text-[var(--primary)]" />
<div>
<h1 className="font-heading text-2xl font-bold">
Notification Rules
</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Configure when and how your team gets notified about pipeline events
</p>
</div>
</div>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="label-upper text-xs">Rules</CardTitle>
<Button
size="sm"
className="h-8 shrink-0"
onClick={() => {
resetForm();
setShowCreate(true);
}}
>
<Plus className="mr-1 h-3 w-3" />
Add Rule
</Button>
</CardHeader>
<CardContent className="space-y-2">
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
) : (rules as any[])?.length === 0 ? (
<p className="py-8 text-center text-sm text-[var(--muted-foreground)]">
No notification rules yet. Create your first rule to get started.
</p>
) : (
<div className="space-y-1.5">
{(rules as any[])?.map((rule: any) => (
<div
key={rule.id}
className={cn(
"flex items-center justify-between rounded-lg border px-3 py-2.5 transition-colors",
rule.isEnabled
? "bg-[var(--primary)]/5 border-[var(--primary)]/20"
: "opacity-60"
)}
>
<div className="flex flex-col gap-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium truncate">
{rule.name}
</span>
<Badge variant="outline" className="text-[9px] h-4 px-1.5 shrink-0">
{getEventLabel(rule.event)}
</Badge>
</div>
<div className="flex items-center gap-1.5 flex-wrap">
{(rule.channels as string[])?.map((ch: string) => (
<Badge
key={ch}
variant="secondary"
className={cn(
"text-[9px] h-4 px-1.5",
ch === "IN_APP"
? "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300"
)}
>
{ch === "IN_APP" ? "In-App" : "Email"}
</Badge>
))}
{(rule.recipientRoles as string[])?.map((role: string) => (
<Badge
key={role}
variant="outline"
className="text-[9px] h-4 px-1.5"
>
{role}
</Badge>
))}
</div>
</div>
<div className="flex items-center gap-2 shrink-0 ml-3">
<Switch
checked={rule.isEnabled}
onCheckedChange={(checked) =>
handleToggleEnabled(rule.id, checked)
}
/>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-[var(--muted-foreground)] hover:text-red-500"
onClick={() => setDeleteConfirm(rule.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Create Rule Dialog */}
<Dialog
open={showCreate}
onOpenChange={(open) => !open && setShowCreate(false)}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Add Notification Rule</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Name */}
<div className="space-y-1.5">
<Label className="label-upper text-[10px]">Name</Label>
<Input
placeholder="Rule name..."
value={formName}
onChange={(e) => setFormName(e.target.value)}
className="h-8 text-sm"
/>
</div>
{/* Event */}
<div className="space-y-1.5">
<Label className="label-upper text-[10px]">Event</Label>
<Select value={formEvent} onValueChange={setFormEvent}>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Select an event..." />
</SelectTrigger>
<SelectContent>
{EVENTS.map((evt) => (
<SelectItem key={evt.value} value={evt.value}>
{evt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Channels */}
<div className="space-y-1.5">
<Label className="label-upper text-[10px]">Channels</Label>
<div className="flex gap-4">
{CHANNELS.map((ch) => (
<label
key={ch.value}
className="flex items-center gap-2 text-sm cursor-pointer"
>
<Checkbox
checked={formChannels.includes(ch.value)}
onCheckedChange={() => toggleChannel(ch.value)}
/>
{ch.label}
</label>
))}
</div>
</div>
{/* Recipient Roles */}
<div className="space-y-1.5">
<Label className="label-upper text-[10px]">
Recipient Roles
</Label>
<div className="flex flex-wrap gap-4">
{RECIPIENT_ROLES.map((role) => (
<label
key={role.value}
className="flex items-center gap-2 text-sm cursor-pointer"
>
<Checkbox
checked={formRoles.includes(role.value)}
onCheckedChange={() => toggleRole(role.value)}
/>
{role.label}
</label>
))}
</div>
</div>
{/* Enabled toggle */}
<div className="flex items-center justify-between">
<Label className="label-upper text-[10px]">Enabled</Label>
<Switch checked={formEnabled} onCheckedChange={setFormEnabled} />
</div>
</div>
<DialogFooter>
<Button
variant="ghost"
size="sm"
onClick={() => setShowCreate(false)}
>
Cancel
</Button>
<Button
size="sm"
onClick={handleCreate}
disabled={
!formName.trim() ||
!formEvent ||
formChannels.length === 0 ||
createRule.isPending
}
>
Create Rule
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete confirmation dialog */}
<Dialog
open={!!deleteConfirm}
onOpenChange={(open) => !open && setDeleteConfirm(null)}
>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Delete Notification Rule</DialogTitle>
</DialogHeader>
<p className="text-sm text-[var(--muted-foreground)]">
This will permanently remove this notification rule. This action
cannot be undone.
</p>
<DialogFooter>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteConfirm(null)}
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => deleteConfirm && handleDelete(deleteConfirm)}
disabled={deleteRule.isPending}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -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",

View file

@ -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<string, string> = {
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<Record<string, Set<string>> | null>(null);
const [dirty, setDirty] = useState<Set<string>>(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<string>()]));
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 (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Shield className="h-6 w-6 text-[var(--primary)]" />
<div>
<h1 className="font-heading text-2xl font-bold">Permissions</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Configure what each role can do in your organization
</p>
</div>
</div>
{isLoading ? (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
) : (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold">Role Permission Matrix</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="label-upper pb-2 pr-4 text-left text-[10px] tracking-wider text-[var(--muted-foreground)]">
Permission
</th>
{ROLES.map((role) => (
<th key={role} className="pb-2 px-3 text-center min-w-[100px]">
<div className="flex flex-col items-center gap-1.5">
<Badge className={cn("text-[10px] px-2", ROLE_COLORS[role])}>
{role}
</Badge>
{dirty.has(role) && role !== "ADMIN" && (
<Button
size="sm"
className="h-5 px-2 text-[10px]"
onClick={() => saveRole(role)}
disabled={updatePerms.isPending}
>
<Save className="mr-1 h-2.5 w-2.5" />
Save
</Button>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{PERMISSION_GROUPS.map((group) => (
<>
<tr key={`group-${group.label}`}>
<td
colSpan={ROLES.length + 1}
className="label-upper pt-4 pb-1 text-[10px] font-semibold tracking-wider text-[var(--primary)]"
>
{group.label}
</td>
</tr>
{group.permissions.map((perm) => (
<tr
key={perm.key}
className="border-b border-[var(--border)]/50 transition-colors hover:bg-[var(--muted)]/30"
>
<td className="py-1.5 pr-4">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-help text-xs">
{perm.label}
</span>
</TooltipTrigger>
<TooltipContent side="right">
<p className="text-xs">{perm.description}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</td>
{ROLES.map((role) => {
const isAdmin = role === "ADMIN";
const hasIt = isAdmin
? true
: perms?.[role]?.has(perm.key) ?? false;
return (
<td key={role} className="py-1.5 px-3 text-center">
<button
type="button"
disabled={isAdmin}
onClick={() => togglePermission(role, perm.key)}
className={cn(
"inline-flex h-6 w-6 items-center justify-center rounded-md transition-all",
isAdmin
? "cursor-not-allowed bg-purple-100/50 text-purple-400 dark:bg-purple-900/30 dark:text-purple-500"
: hasIt
? "bg-[var(--primary)]/10 text-[var(--primary)] hover:bg-[var(--primary)]/20"
: "bg-[var(--muted)]/50 text-[var(--muted-foreground)]/40 hover:bg-[var(--muted)] hover:text-[var(--muted-foreground)]"
)}
>
{hasIt ? (
<Check className="h-3.5 w-3.5" />
) : (
<X className="h-3 w-3" />
)}
</button>
</td>
);
})}
</tr>
))}
</>
))}
</tbody>
</table>
</div>
<div className="mt-4 flex items-center gap-2 rounded-lg border border-[var(--border)] bg-[var(--muted)]/30 px-3 py-2">
<Shield className="h-3.5 w-3.5 text-purple-500 shrink-0" />
<p className="text-[11px] text-[var(--muted-foreground)]">
Admin role always has all permissions and cannot be modified.
Changes are saved per-role and take effect immediately.
</p>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View file

@ -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<string, unknown>) => {
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 (
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<div className="grid gap-4 lg:grid-cols-2">
<Skeleton className="h-[500px]" />
<Skeleton className="h-[500px]" />
</div>
</div>
);
}
if (!pl) {
return (
<div className="py-12 text-center text-sm text-[var(--muted-foreground)]">
Pipeline not found.
</div>
);
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/settings/pipelines">
<Button variant="ghost" size="icon" className="h-7 w-7">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<GitBranch className="h-5 w-5 text-[var(--primary)]" />
<div>
<h1 className="font-heading text-xl font-bold">{pl.name}</h1>
{pl.description && (
<p className="text-xs text-[var(--muted-foreground)]">{pl.description}</p>
)}
</div>
{pl.isDefault && (
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300 text-[9px] h-4 px-1.5">
<Star className="mr-0.5 h-2.5 w-2.5" />
Default
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={handleToggleDefault}
>
<Star className="mr-1 h-3 w-3" />
{pl.isDefault ? "Remove Default" : "Set Default"}
</Button>
<span className="text-[10px] text-[var(--muted-foreground)]">
{pl._count?.projects ?? 0} projects
</span>
</div>
</div>
{/* Validation banner */}
{val && (
<PipelineValidationBanner
errors={val.errors ?? []}
warnings={val.warnings ?? []}
/>
)}
{/* Two-panel layout */}
<div className="grid gap-4 lg:grid-cols-[320px_1fr]">
{/* Left: Stage list */}
<div className="rounded-lg border bg-[var(--card)] p-3">
<div className="mb-2 flex items-center justify-between">
<span className="label-upper text-[10px] font-semibold tracking-wider text-[var(--muted-foreground)]">
Stages
</span>
<span className="text-[10px] text-[var(--muted-foreground)]">
{stages.length} total
</span>
</div>
<PipelineStageList
stages={stages}
selectedStageId={selectedStageId}
onSelect={selectStage}
onReorder={handleReorder}
onAddStage={() => setShowAddStage(true)}
/>
</div>
{/* Right: Dependency graph */}
<div className="rounded-lg border bg-[var(--card)] overflow-hidden" style={{ minHeight: 500 }}>
<div className="border-b px-3 py-2">
<span className="label-upper text-[10px] font-semibold tracking-wider text-[var(--muted-foreground)]">
Dependency Graph
</span>
</div>
<div style={{ height: 460 }}>
<PipelineGraph
stages={stages}
onConnect={handleConnect}
onDeleteEdge={handleDeleteEdge}
onSelectStage={selectStage}
/>
</div>
</div>
</div>
{/* Stage edit sheet */}
<StageEditSheet
stage={selectedStage}
open={!!selectedStageId}
onOpenChange={(open) => !open && selectStage(null)}
onSave={handleUpdateStage}
onDelete={handleDeleteStage}
/>
{/* Add stage dialog */}
<Dialog open={showAddStage} onOpenChange={setShowAddStage}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Add Stage</DialogTitle>
</DialogHeader>
<Input
placeholder="Stage name..."
value={newStageName}
onChange={(e) => setNewStageName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAddStage()}
className="h-8 text-sm"
autoFocus
/>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={() => setShowAddStage(false)}>
Cancel
</Button>
<Button size="sm" onClick={handleAddStage} disabled={!newStageName.trim() || addStage.isPending}>
Add
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -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<string | null>(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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<GitBranch className="h-6 w-6 text-[var(--primary)]" />
<div>
<h1 className="font-heading text-2xl font-bold">Pipeline Templates</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Define production pipelines for your organization
</p>
</div>
</div>
<Button size="sm" onClick={() => setShowCreate(true)}>
<Plus className="mr-1 h-3.5 w-3.5" />
New Pipeline
</Button>
</div>
{isLoading ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-40" />
))}
</div>
) : (pipelines as any[])?.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<GitBranch className="mx-auto mb-3 h-10 w-10 text-[var(--muted-foreground)]/50" />
<p className="text-sm text-[var(--muted-foreground)]">
No pipeline templates yet. Create your first one to get started.
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{(pipelines as any[])?.map((pipeline: any) => (
<Card
key={pipeline.id}
className="group relative transition-colors hover:bg-[var(--muted)]/30"
>
<Link href={`/settings/pipelines/${pipeline.id}`}>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
{pipeline.name}
{pipeline.isDefault && (
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300 text-[9px] h-4 px-1.5">
<Star className="mr-0.5 h-2.5 w-2.5" />
Default
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent>
{pipeline.description && (
<p className="mb-2 text-xs text-[var(--muted-foreground)] line-clamp-2">
{pipeline.description}
</p>
)}
<div className="flex items-center gap-3 text-[10px] text-[var(--muted-foreground)]">
<span className="label-upper">
{pipeline.stages?.length ?? 0} stages
</span>
<span className="label-upper">
{pipeline._count?.projects ?? 0} projects
</span>
</div>
</CardContent>
</Link>
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={(e) => {
e.preventDefault();
setDupDialog({ id: pipeline.id, name: pipeline.name });
setDupName(`${pipeline.name} (copy)`);
}}
>
<Copy className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-[var(--muted-foreground)] hover:text-red-500"
onClick={(e) => {
e.preventDefault();
setArchiveConfirm(pipeline.id);
}}
>
<Archive className="h-3 w-3" />
</Button>
</div>
</Card>
))}
</div>
)}
{/* Create dialog */}
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>New Pipeline Template</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<Input
placeholder="Pipeline name..."
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
className="h-8 text-sm"
/>
<Input
placeholder="Description (optional)..."
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
className="h-8 text-sm"
/>
</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={() => setShowCreate(false)}>
Cancel
</Button>
<Button size="sm" onClick={handleCreate} disabled={!newName.trim() || createPipeline.isPending}>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Duplicate dialog */}
<Dialog open={!!dupDialog} onOpenChange={(open) => !open && setDupDialog(null)}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Duplicate Pipeline</DialogTitle>
</DialogHeader>
<Input
placeholder="New name..."
value={dupName}
onChange={(e) => setDupName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleDuplicate()}
className="h-8 text-sm"
/>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={() => setDupDialog(null)}>
Cancel
</Button>
<Button size="sm" onClick={handleDuplicate} disabled={!dupName.trim() || duplicatePipeline.isPending}>
Duplicate
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Archive confirm dialog */}
<Dialog open={!!archiveConfirm} onOpenChange={(open) => !open && setArchiveConfirm(null)}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Archive Pipeline</DialogTitle>
</DialogHeader>
<p className="text-sm text-[var(--muted-foreground)]">
Archived pipelines won't appear in the list but existing projects using them won't be affected.
</p>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={() => setArchiveConfirm(null)}>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => archiveConfirm && handleArchive(archiveConfirm)}
disabled={archivePipeline.isPending}
>
Archive
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -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<string, string> = {
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 (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Users className="h-6 w-6 text-[var(--primary)]" />
<div>
<h1 className="font-heading text-2xl font-bold">Team</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Manage team members and send invitations to join your organization
</p>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-2">
{/* Current Members */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Users className="h-4 w-4" />
<span className="label-upper">Current Members</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-1.5">
{usersLoading ? (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
) : (users as any[])?.length === 0 ? (
<p className="py-8 text-center text-sm text-[var(--muted-foreground)]">
No team members found.
</p>
) : (
(users as any[])?.map((user: any) => (
<div
key={user.id}
className="flex items-center justify-between rounded-lg border px-3 py-2.5 transition-colors hover:bg-[var(--muted)]/50"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">
{user.name || "Unnamed"}
</span>
<Badge
variant="secondary"
className={cn("text-[9px] h-4 shrink-0 px-1.5", ROLE_STYLES[user.role] || "")}
>
{user.role}
</Badge>
</div>
<div className="flex items-center gap-2 text-[11px] text-[var(--muted-foreground)]">
<Mail className="h-3 w-3 shrink-0" />
<span className="truncate">{user.email}</span>
{user.department && (
<>
<span className="shrink-0">·</span>
<span className="truncate">{user.department}</span>
</>
)}
</div>
</div>
</div>
))
)}
</CardContent>
</Card>
{/* Invitations */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<UserPlus className="h-4 w-4" />
<span className="label-upper">Invitations</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Invite form */}
<div className="flex gap-2">
<Input
placeholder="Email address..."
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleInvite()}
className="h-8 text-sm"
/>
<Select value={inviteRole} onValueChange={setInviteRole}>
<SelectTrigger className="h-8 w-[120px] shrink-0 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ADMIN">Admin</SelectItem>
<SelectItem value="PRODUCER">Producer</SelectItem>
<SelectItem value="ARTIST">Artist</SelectItem>
</SelectContent>
</Select>
<Button
size="sm"
className="h-8 shrink-0"
onClick={handleInvite}
disabled={!inviteEmail.trim() || createInvitation.isPending}
>
<UserPlus className="mr-1 h-3 w-3" />
Send Invite
</Button>
</div>
{/* Invitations list */}
{invitationsLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
) : (invitations as any[])?.length === 0 ? (
<p className="py-8 text-center text-sm text-[var(--muted-foreground)]">
No invitations sent yet. Invite your first team member above.
</p>
) : (
<div className="space-y-1.5">
{(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 (
<div
key={inv.id}
className="flex items-center justify-between rounded-lg border px-3 py-2.5 transition-colors hover:bg-[var(--muted)]/50"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{inv.email}</span>
<Badge
variant="secondary"
className={cn("text-[9px] h-4 shrink-0 px-1.5", ROLE_STYLES[inv.role] || "")}
>
{inv.role}
</Badge>
<Badge
variant="secondary"
className={cn("text-[9px] h-4 shrink-0 px-1.5", status.className)}
>
<StatusIcon className="mr-0.5 h-2.5 w-2.5" />
{status.label}
</Badge>
</div>
<p className="text-[11px] text-[var(--muted-foreground)]">
Invited by {inv.invitedBy?.name || inv.invitedBy?.email || "Unknown"}
</p>
</div>
{isPending && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-[var(--muted-foreground)] hover:text-red-500"
onClick={() => handleRevoke(inv.id)}
disabled={revokeInvitation.isPending}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View file

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

View file

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

View file

@ -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,

View file

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

View file

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

View file

@ -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: [] });
}

View file

@ -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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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) {

View file

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

View file

@ -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 {

View file

@ -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 {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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

View file

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

View file

@ -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 };

View file

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

View file

@ -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 {

View file

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

View file

@ -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 {

View file

@ -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 */}
<path
d={edgePath}
fill="none"
stroke="transparent"
strokeWidth={20}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
/>
<BaseEdge
id={id}
path={edgePath}
markerEnd={markerEnd}
style={{
stroke: "var(--border)",
strokeWidth: 2,
...style,
}}
/>
{hovered && edgeData?.onDelete && (
<EdgeLabelRenderer>
<button
type="button"
className="nodrag nopan absolute flex items-center justify-center size-5 rounded-full bg-destructive text-white shadow-md transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]"
style={{
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: "all",
}}
onClick={edgeData.onDelete}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
aria-label="Delete dependency"
>
<X className="size-3" />
</button>
</EdgeLabelRenderer>
)}
</>
);
}

View file

@ -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<number, typeof sorted>();
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 (
<div className="h-full w-full rounded-lg border bg-[var(--card)]">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={handleConnect}
onNodeClick={handleNodeClick}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView
fitViewOptions={{ padding: 0.3 }}
proOptions={{ hideAttribution: true }}
className="[&_.react-flow__node]:!cursor-pointer"
>
<Background
gap={16}
size={1}
color="var(--border)"
style={{ opacity: 0.4 }}
/>
<Controls
showInteractive={false}
className="!rounded-lg !border !border-[var(--border)] !bg-[var(--card)] !shadow-[var(--shadow-sm)] [&>button]:!border-[var(--border)] [&>button]:!bg-[var(--card)] [&>button]:!text-[var(--foreground)] [&>button:hover]:!bg-[var(--accent)]"
/>
</ReactFlow>
</div>
);
}

View file

@ -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 (
<div className="flex flex-col gap-1">
<h3 className="label-upper text-[var(--muted-foreground)] mb-2">
Pipeline Stages
</h3>
<div className="flex flex-col gap-0.5">
{sorted.map((stage, index) => (
<button
key={stage.id}
type="button"
onClick={() => onSelect(stage.id)}
className={cn(
"group flex items-center gap-2 rounded-lg px-3 py-2 text-left transition-all duration-150",
"hover:bg-[var(--accent)] hover:text-[var(--accent-foreground)]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]",
selectedStageId === stage.id &&
"ring-2 ring-[var(--primary)] bg-[var(--accent)]"
)}
>
{/* Order number */}
<span className="font-mono text-xs text-[var(--muted-foreground)] w-5 shrink-0 text-right">
{index + 1}.
</span>
{/* Color dot */}
{stage.color && (
<span
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: stage.color }}
/>
)}
{/* Name + badges */}
<div className="flex items-center gap-1.5 min-w-0 flex-1">
<span className="font-semibold text-sm truncate">
{stage.name}
</span>
{stage.isCriticalGate && (
<Badge
variant="outline"
className="h-4 gap-0.5 border-amber-500/60 text-amber-600 dark:text-amber-400 text-[9px] px-1.5 shrink-0"
>
<Shield className="size-2.5" />
GATE
</Badge>
)}
{stage.isOptional && (
<Badge
variant="secondary"
className="h-4 text-[9px] px-1.5 shrink-0"
>
Optional
</Badge>
)}
</div>
{/* Estimated days */}
{stage.estimatedDays != null && (
<span className="flex items-center gap-0.5 text-[10px] text-[var(--muted-foreground)] shrink-0">
<Clock className="size-2.5" />
{stage.estimatedDays}d
</span>
)}
{/* Reorder buttons */}
<div className="flex shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon-xs"
disabled={index === 0}
onClick={(e) => {
e.stopPropagation();
moveStage(index, "up");
}}
aria-label={`Move ${stage.name} up`}
>
<ArrowUp className="size-3" />
</Button>
<Button
variant="ghost"
size="icon-xs"
disabled={index === sorted.length - 1}
onClick={(e) => {
e.stopPropagation();
moveStage(index, "down");
}}
aria-label={`Move ${stage.name} down`}
>
<ArrowDown className="size-3" />
</Button>
</div>
</button>
))}
</div>
<Button
variant="outline"
size="sm"
className="mt-3 w-full"
onClick={onAddStage}
>
<Plus className="size-3.5" />
Add Stage
</Button>
</div>
);
}

View file

@ -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 (
<div className="flex flex-col gap-2">
{errors.length > 0 && (
<div
role="alert"
className={cn(
"flex gap-3 rounded-lg border px-4 py-3",
"border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-400"
)}
>
<AlertCircle className="size-4 shrink-0 mt-0.5" />
<div className="flex flex-col gap-1 min-w-0">
<span className="label-upper text-[10px] font-semibold">
Errors
</span>
<ul className="list-disc list-inside text-sm space-y-0.5">
{errors.map((error, i) => (
<li key={i}>{error}</li>
))}
</ul>
</div>
</div>
)}
{warnings.length > 0 && (
<div
role="status"
className={cn(
"flex gap-3 rounded-lg border px-4 py-3",
"border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400"
)}
>
<AlertTriangle className="size-4 shrink-0 mt-0.5" />
<div className="flex flex-col gap-1 min-w-0">
<span className="label-upper text-[10px] font-semibold">
Warnings
</span>
<ul className="list-disc list-inside text-sm space-y-0.5">
{warnings.map((warning, i) => (
<li key={i}>{warning}</li>
))}
</ul>
</div>
</div>
)}
</div>
);
}

View file

@ -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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-md overflow-y-auto">
<SheetHeader>
<SheetTitle className="text-base">Edit Stage</SheetTitle>
</SheetHeader>
<div className="flex flex-col gap-5 px-4">
{/* Name */}
<div className="flex flex-col gap-1.5">
<Label htmlFor="stage-name" className="label-upper text-[10px]">
Name
</Label>
<Input
id="stage-name"
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="e.g. Model Prep"
/>
</div>
{/* Slug */}
<div className="flex flex-col gap-1.5">
<Label htmlFor="stage-slug" className="label-upper text-[10px]">
Slug
</Label>
<Input
id="stage-slug"
value={slug}
onChange={(e) => handleSlugChange(e.target.value)}
placeholder="e.g. model-prep"
className="font-mono text-xs"
/>
</div>
{/* Description */}
<div className="flex flex-col gap-1.5">
<Label
htmlFor="stage-description"
className="label-upper text-[10px]"
>
Description
</Label>
<Textarea
id="stage-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional description..."
rows={3}
/>
</div>
{/* Estimated Days */}
<div className="flex flex-col gap-1.5">
<Label htmlFor="stage-days" className="label-upper text-[10px]">
Estimated Days
</Label>
<Input
id="stage-days"
type="number"
min={0}
value={estimatedDays}
onChange={(e) => setEstimatedDays(e.target.value)}
placeholder="e.g. 5"
className="w-24"
/>
</div>
{/* Color */}
<div className="flex flex-col gap-1.5">
<Label htmlFor="stage-color" className="label-upper text-[10px]">
Color
</Label>
<div className="flex items-center gap-2">
<Input
id="stage-color"
value={color}
onChange={(e) => setColor(e.target.value)}
placeholder="#3b82f6"
className="font-mono text-xs w-32"
/>
{color && (
<span
className="size-6 rounded-md border border-[var(--border)] shrink-0"
style={{ backgroundColor: color }}
/>
)}
</div>
</div>
<Separator />
{/* Toggles */}
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-0.5">
<Label
htmlFor="stage-gate"
className="text-sm font-medium cursor-pointer"
>
Critical Gate
</Label>
<span className="text-[11px] text-[var(--muted-foreground)]">
Requires approval before proceeding
</span>
</div>
<Switch
id="stage-gate"
checked={isCriticalGate}
onCheckedChange={setIsCriticalGate}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex flex-col gap-0.5">
<Label
htmlFor="stage-optional"
className="text-sm font-medium cursor-pointer"
>
Optional
</Label>
<span className="text-[11px] text-[var(--muted-foreground)]">
Can be skipped in the pipeline
</span>
</div>
<Switch
id="stage-optional"
checked={isOptional}
onCheckedChange={setIsOptional}
/>
</div>
</div>
</div>
<SheetFooter>
<div className="flex w-full gap-2">
<Button
variant="destructive"
size="sm"
onClick={handleDelete}
className="mr-auto"
>
<Trash2 className="size-3.5" />
Delete
</Button>
<Button size="sm" onClick={handleSave}>
<Save className="size-3.5" />
Save
</Button>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
);
}

View file

@ -0,0 +1,71 @@
"use client";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { Shield } from "lucide-react";
export interface StageNodeData {
name: string;
isCriticalGate: boolean;
isOptional: boolean;
color: string | null;
[key: string]: unknown;
}
export function StageNode({ data, selected }: NodeProps) {
const { name, isCriticalGate, isOptional, color } =
data as unknown as StageNodeData;
return (
<div
className={cn(
"relative flex items-center gap-2 rounded-lg border bg-[var(--card)] px-3 py-2",
"shadow-[var(--shadow-xs)] transition-all duration-150",
"w-[160px] h-[60px]",
selected &&
"ring-2 ring-[var(--primary)] shadow-[var(--shadow-md)]"
)}
>
{/* Color bar on left edge */}
{color && (
<div
className="absolute inset-y-0 left-0 w-1 rounded-l-lg"
style={{ backgroundColor: color }}
/>
)}
<Handle
type="target"
position={Position.Top}
className="!w-2 !h-2 !bg-[var(--muted-foreground)] !border-[var(--border)] !-top-1"
/>
<div className="flex flex-col gap-0.5 min-w-0 pl-1">
<div className="flex items-center gap-1">
{isCriticalGate && (
<Shield className="size-3 shrink-0 text-amber-500" />
)}
<span className="text-xs font-semibold truncate leading-tight">
{name}
</span>
</div>
{isOptional && (
<Badge
variant="secondary"
className="h-3.5 text-[8px] px-1 w-fit"
>
Optional
</Badge>
)}
</div>
<Handle
type="source"
position={Position.Bottom}
className="!w-2 !h-2 !bg-[var(--muted-foreground)] !border-[var(--border)] !-bottom-1"
/>
</div>
);
}

View file

@ -0,0 +1,83 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Request failed: ${res.status}`);
}
return res.json();
}
export function useCustomFields(entityType?: string) {
const params = entityType ? `?entityType=${entityType}` : "";
return useQuery({
queryKey: ["custom-fields", entityType],
queryFn: () => fetchJson<any[]>(`/api/org/fields${params}`),
});
}
export function useCreateCustomField() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: {
entityType: string;
fieldName: string;
fieldType: string;
fieldOptions?: { options: string[] };
isRequired?: boolean;
order?: number;
}) =>
fetchJson("/api/org/fields", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["custom-fields"] });
},
});
}
export function useUpdateCustomField() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
...data
}: {
id: string;
fieldName?: string;
fieldType?: string;
fieldOptions?: { options: string[] } | null;
isRequired?: boolean;
order?: number;
}) =>
fetchJson(`/api/org/fields/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["custom-fields"] });
},
});
}
export function useDeleteCustomField() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
fetchJson(`/api/org/fields/${id}`, {
method: "DELETE",
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["custom-fields"] });
},
});
}

View file

@ -0,0 +1,45 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Request failed: ${res.status}`);
}
return res.json();
}
export function useInvitations() {
return useQuery({
queryKey: ["invitations"],
queryFn: () => fetchJson("/api/org/invitations"),
});
}
export function useCreateInvitation() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { email: string; role?: string }) =>
fetchJson("/api/org/invitations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["invitations"] });
},
});
}
export function useRevokeInvitation() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
fetchJson(`/api/org/invitations/${id}`, { method: "DELETE" }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["invitations"] });
},
});
}

View file

@ -0,0 +1,78 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Request failed: ${res.status}`);
}
return res.json();
}
export function useNotificationRules() {
return useQuery({
queryKey: ["notification-rules"],
queryFn: () => fetchJson("/api/org/notification-rules"),
});
}
export function useCreateNotificationRule() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: {
name: string;
event: string;
conditions?: { field: string; operator: string; value: any }[];
channels: string[];
recipientRoles: string[];
isEnabled?: boolean;
}) =>
fetchJson("/api/org/notification-rules", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["notification-rules"] });
},
});
}
export function useUpdateNotificationRule() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
id,
...data
}: {
id: string;
name?: string;
event?: string;
conditions?: { field: string; operator: string; value: any }[] | null;
channels?: string[];
recipientRoles?: string[];
isEnabled?: boolean;
}) =>
fetchJson(`/api/org/notification-rules/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["notification-rules"] });
},
});
}
export function useDeleteNotificationRule() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
fetchJson(`/api/org/notification-rules/${id}`, { method: "DELETE" }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["notification-rules"] });
},
});
}

View file

@ -0,0 +1,35 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Request failed: ${res.status}`);
}
return res.json();
}
export function useOrgPermissions() {
return useQuery({
queryKey: ["org-permissions"],
queryFn: () => fetchJson<Record<string, string[]>>("/api/org/permissions"),
});
}
export function useUpdateRolePermissions() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { role: string; permissions: string[] }) =>
fetchJson("/api/org/permissions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["org-permissions"] });
},
});
}

170
src/hooks/use-pipelines.ts Normal file
View file

@ -0,0 +1,170 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Request failed: ${res.status}`);
}
return res.json();
}
function jsonPost(url: string, data: unknown) {
return fetchJson(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
}
function jsonPatch(url: string, data: unknown) {
return fetchJson(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
}
function jsonPut(url: string, data: unknown) {
return fetchJson(url, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
}
function jsonDelete(url: string, data?: unknown) {
return fetchJson(url, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: data ? JSON.stringify(data) : undefined,
});
}
// ─── Pipeline Templates ─────────────────────────────────
export function usePipelineTemplates() {
return useQuery({
queryKey: ["pipelines"],
queryFn: () => fetchJson("/api/pipelines"),
});
}
export function usePipelineTemplate(pipelineId: string) {
return useQuery({
queryKey: ["pipelines", pipelineId],
queryFn: () => fetchJson(`/api/pipelines/${pipelineId}`),
enabled: !!pipelineId,
});
}
export function useCreatePipeline() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { name: string; description?: string }) =>
jsonPost("/api/pipelines", data),
onSuccess: () => qc.invalidateQueries({ queryKey: ["pipelines"] }),
});
}
export function useUpdatePipeline(pipelineId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { name?: string; description?: string | null; isDefault?: boolean }) =>
jsonPatch(`/api/pipelines/${pipelineId}`, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["pipelines"] });
qc.invalidateQueries({ queryKey: ["pipelines", pipelineId] });
},
});
}
export function useArchivePipeline() {
const qc = useQueryClient();
return useMutation({
mutationFn: (pipelineId: string) =>
fetchJson(`/api/pipelines/${pipelineId}`, { method: "DELETE" }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["pipelines"] }),
});
}
export function useDuplicatePipeline() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { pipelineId: string; name: string }) =>
jsonPost(`/api/pipelines/${data.pipelineId}/duplicate`, { name: data.name }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["pipelines"] }),
});
}
// ─── Stages ─────────────────────────────────────────────
export function useAddStage(pipelineId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { name: string; slug?: string; order: number; isCriticalGate?: boolean; isOptional?: boolean; description?: string | null; estimatedDays?: number | null; color?: string | null }) =>
jsonPost(`/api/pipelines/${pipelineId}/stages`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ["pipelines", pipelineId] }),
});
}
export function useUpdateStage(pipelineId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { stageId: string } & Record<string, unknown>) => {
const { stageId, ...rest } = data;
return jsonPatch(`/api/pipelines/${pipelineId}/stages/${stageId}`, rest);
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["pipelines", pipelineId] }),
});
}
export function useRemoveStage(pipelineId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (stageId: string) =>
fetchJson(`/api/pipelines/${pipelineId}/stages/${stageId}`, { method: "DELETE" }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["pipelines", pipelineId] }),
});
}
export function useReorderStages(pipelineId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (stageIds: string[]) =>
jsonPut(`/api/pipelines/${pipelineId}/stages/reorder`, { stageIds }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["pipelines", pipelineId] }),
});
}
// ─── Dependencies ───────────────────────────────────────
export function useAddDependency(pipelineId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { stageId: string; prerequisiteId: string }) =>
jsonPost(`/api/pipelines/${pipelineId}/dependencies`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ["pipelines", pipelineId] }),
});
}
export function useRemoveDependency(pipelineId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { stageId: string; prerequisiteId: string }) =>
jsonDelete(`/api/pipelines/${pipelineId}/dependencies`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ["pipelines", pipelineId] }),
});
}
// ─── Validation ─────────────────────────────────────────
export function usePipelineValidation(pipelineId: string) {
return useQuery({
queryKey: ["pipelines", pipelineId, "validation"],
queryFn: () => fetchJson(`/api/pipelines/${pipelineId}/validate`),
enabled: !!pipelineId,
});
}

View file

@ -35,6 +35,10 @@ export function badRequest(message: string) {
return NextResponse.json({ error: message }, { status: 400 });
}
export function forbidden(message = "Forbidden") {
return NextResponse.json({ error: message }, { status: 403 });
}
export function notFound(message = "Not found") {
return NextResponse.json({ error: message }, { status: 404 });
}

View file

@ -0,0 +1,92 @@
/**
* Stage Resolver normalizes the old `PipelineStageTemplate` and new
* `PipelineStageDefinition` into a unified shape so downstream code
* doesn't need to know which system is in use.
*/
export interface ResolvedStageDefinition {
id: string;
name: string;
slug: string;
order: number;
isCriticalGate: boolean;
isOptional: boolean;
description: string | null;
estimatedDays: number | null;
color: string | null;
customStatuses: unknown | null;
dependsOn: { prerequisiteId: string }[];
}
interface OldTemplate {
id: string;
name: string;
slug: string;
order: number;
isCriticalGate: boolean;
isOptional: boolean;
description: string | null;
estimatedDays: number | null;
dependsOn?: { prerequisiteId: string }[];
}
interface NewStageDefinition {
id: string;
name: string;
slug: string;
order: number;
isCriticalGate: boolean;
isOptional: boolean;
description: string | null;
estimatedDays: number | null;
color: string | null;
customStatuses: unknown | null;
dependsOn?: { prerequisiteId: string }[];
}
/**
* Resolve a deliverable stage's definition from either the old template
* or the new stage definition. Prefers stageDefinition if both are present.
*/
export function resolveStageDefinition(stage: {
template?: OldTemplate | null;
stageDefinition?: NewStageDefinition | null;
}): ResolvedStageDefinition {
// Prefer the new dynamic definition
if (stage.stageDefinition) {
const sd = stage.stageDefinition;
return {
id: sd.id,
name: sd.name,
slug: sd.slug,
order: sd.order,
isCriticalGate: sd.isCriticalGate,
isOptional: sd.isOptional,
description: sd.description,
estimatedDays: sd.estimatedDays,
color: sd.color,
customStatuses: sd.customStatuses,
dependsOn: sd.dependsOn ?? [],
};
}
// Fall back to old global template
if (stage.template) {
const t = stage.template;
return {
id: t.id,
name: t.name,
slug: t.slug,
order: t.order,
isCriticalGate: t.isCriticalGate,
isOptional: t.isOptional,
description: t.description,
estimatedDays: t.estimatedDays,
color: null,
customStatuses: null,
dependsOn: t.dependsOn ?? [],
};
}
throw new Error("Stage has neither template nor stageDefinition");
}

85
src/lib/rbac/org-scope.ts Normal file
View file

@ -0,0 +1,85 @@
import { prisma } from "@/lib/prisma";
/**
* Verify that a resource belongs to the given organization.
* Throws if the resource doesn't exist or belongs to a different org.
*/
export async function assertOrgAccess(
model: "project" | "deliverable" | "deliverableStage",
resourceId: string,
organizationId: string
): Promise<void> {
let orgId: string | null = null;
switch (model) {
case "project": {
const project = await prisma.project.findUnique({
where: { id: resourceId },
select: { organizationId: true },
});
if (!project) throw new OrgAccessError("Project not found");
orgId = project.organizationId;
break;
}
case "deliverable": {
const deliverable = await prisma.deliverable.findUnique({
where: { id: resourceId },
select: { project: { select: { organizationId: true } } },
});
if (!deliverable) throw new OrgAccessError("Deliverable not found");
orgId = deliverable.project.organizationId;
break;
}
case "deliverableStage": {
const stage = await prisma.deliverableStage.findUnique({
where: { id: resourceId },
select: {
deliverable: {
select: { project: { select: { organizationId: true } } },
},
},
});
if (!stage) throw new OrgAccessError("Stage not found");
orgId = stage.deliverable.project.organizationId;
break;
}
}
if (orgId !== organizationId) {
throw new OrgAccessError("Access denied");
}
}
/**
* Returns a Prisma `where` clause fragment that scopes to an org.
* Use in findMany/count queries.
*/
export function orgWhere(organizationId: string) {
return { organizationId };
}
/**
* Returns a `where` clause fragment that scopes deliverable queries
* through their parent project's org.
*/
export function deliverableOrgWhere(organizationId: string) {
return { project: { organizationId } };
}
/**
* Returns a `where` clause fragment that scopes stage queries
* through their deliverable's parent project's org.
*/
export function stageOrgWhere(organizationId: string) {
return {
deliverable: { project: { organizationId } },
};
}
export class OrgAccessError extends Error {
code = "ORG_ACCESS_DENIED";
constructor(message: string) {
super(message);
this.name = "OrgAccessError";
}
}

View file

@ -0,0 +1,82 @@
import type { Role, Permission } from "@/generated/prisma/client";
import { prisma } from "@/lib/prisma";
/**
* Default permissions per role. Used to seed new organizations
* and as a fallback when org-specific permissions aren't configured.
*/
export const DEFAULT_PERMISSIONS: Record<Role, Permission[]> = {
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",
],
};
/**
* Check whether a role has a specific permission within an organization.
* Falls back to DEFAULT_PERMISSIONS if no org-specific overrides exist.
*/
export async function hasPermission(
organizationId: string,
role: Role,
permission: Permission
): Promise<boolean> {
// ADMIN always has all permissions (safety net)
if (role === "ADMIN") return true;
// Check org-specific overrides first
const orgPermsCount = await prisma.orgRolePermission.count({
where: { organizationId },
});
if (orgPermsCount > 0) {
const found = await prisma.orgRolePermission.findUnique({
where: {
organizationId_role_permission: {
organizationId,
role,
permission,
},
},
});
return !!found;
}
// Fall back to defaults
return DEFAULT_PERMISSIONS[role]?.includes(permission) ?? false;
}
/**
* Check permission and throw if denied. Used inside service functions.
*/
export async function requirePermission(
organizationId: string,
role: Role,
permission: Permission
): Promise<void> {
const allowed = await hasPermission(organizationId, role, permission);
if (!allowed) {
const err = new Error(`Missing permission: ${permission}`);
(err as any).code = "FORBIDDEN";
throw err;
}
}

View file

@ -0,0 +1,63 @@
import { NextResponse } from "next/server";
import type { Role, Permission } from "@/generated/prisma/client";
import { getAuthSession, forbidden } from "@/lib/api-utils";
import { hasPermission } from "./permissions";
export interface AuthSession {
user: {
id: string;
name?: string | null;
email?: string | null;
image?: string | null;
role: Role;
organizationId: string;
};
}
type RequireAuthResult =
| { session: AuthSession; error: null }
| { session: null; error: NextResponse };
/**
* Unified auth + permission check for API routes.
*
* Usage:
* const { session, error } = await requireAuth("PROJECT_CREATE");
* if (error) return error;
* // session is typed with non-null organizationId
*
* Without a permission argument, it only verifies the user is authenticated
* and belongs to an organization.
*/
export async function requireAuth(
permission?: Permission
): Promise<RequireAuthResult> {
const { session, error } = await getAuthSession();
if (error) return { session: null, error };
const orgId = session!.user.organizationId;
if (!orgId) {
return {
session: null,
error: forbidden("User is not part of an organization"),
};
}
if (permission) {
const allowed = await hasPermission(orgId, session!.user.role as Role, permission);
if (!allowed) {
return {
session: null,
error: forbidden(`Missing permission: ${permission}`),
};
}
}
return {
session: {
...session!,
user: { ...session!.user, organizationId: orgId },
} as AuthSession,
error: null,
};
}

View file

@ -0,0 +1,73 @@
import { prisma } from "@/lib/prisma";
import type {
CreateCustomFieldInput,
UpdateCustomFieldInput,
} from "@/lib/validators/custom-field";
/**
* List custom field definitions for an organization.
* Optionally filter by entityType ("PROJECT" | "DELIVERABLE").
*/
export async function listCustomFields(
orgId: string,
entityType?: string
) {
return prisma.customFieldDefinition.findMany({
where: {
organizationId: orgId,
...(entityType ? { entityType } : {}),
},
orderBy: [{ entityType: "asc" }, { order: "asc" }, { fieldName: "asc" }],
});
}
/**
* Create a custom field definition.
*/
export async function createCustomField(
data: CreateCustomFieldInput,
orgId: string
) {
return prisma.customFieldDefinition.create({
data: {
organizationId: orgId,
entityType: data.entityType,
fieldName: data.fieldName,
fieldType: data.fieldType,
fieldOptions: data.fieldOptions ?? undefined,
isRequired: data.isRequired ?? false,
order: data.order ?? 0,
},
});
}
/**
* Update a custom field definition (verifies org ownership).
*/
export async function updateCustomField(
id: string,
data: UpdateCustomFieldInput,
orgId: string
) {
return prisma.customFieldDefinition.update({
where: { id, organizationId: orgId },
data: {
...(data.fieldName !== undefined ? { fieldName: data.fieldName } : {}),
...(data.fieldType !== undefined ? { fieldType: data.fieldType } : {}),
...(data.fieldOptions !== undefined
? { fieldOptions: data.fieldOptions ?? undefined }
: {}),
...(data.isRequired !== undefined ? { isRequired: data.isRequired } : {}),
...(data.order !== undefined ? { order: data.order } : {}),
},
});
}
/**
* Delete a custom field definition (verifies org ownership).
*/
export async function deleteCustomField(id: string, orgId: string) {
return prisma.customFieldDefinition.delete({
where: { id, organizationId: orgId },
});
}

View file

@ -19,11 +19,34 @@ export async function createDeliverable(
projectId: string,
data: CreateDeliverableInput
) {
// Fetch all pipeline templates with their dependencies
const templates = await prisma.pipelineStageTemplate.findMany({
// Fetch project to get organizationId and pipeline template
const project = await prisma.project.findUnique({
where: { id: projectId },
select: {
organizationId: true,
pipelineTemplateId: true,
pipelineTemplate: {
include: {
stages: {
include: { dependsOn: true },
orderBy: { order: "asc" },
},
},
},
},
});
if (!project) throw new Error("Project not found");
// Use dynamic pipeline stages if project has a template, else fall back to global
const useDynamic = !!project.pipelineTemplate;
const dynamicStages = project.pipelineTemplate?.stages ?? [];
// Always fetch global templates (needed for templateId FK even with dynamic pipelines)
const globalTemplates = await prisma.pipelineStageTemplate.findMany({
include: { dependsOn: true },
orderBy: { order: "asc" },
});
const globalBySlug = new Map(globalTemplates.map((t) => [t.slug, t]));
return prisma.$transaction(async (tx) => {
const deliverable = await tx.deliverable.create({
@ -32,6 +55,7 @@ export async function createDeliverable(
priority: data.priority,
notes: data.notes,
projectId,
organizationId: project.organizationId,
dueDate: data.dueDate ? new Date(data.dueDate) : null,
cmfSku: data.cmfSku,
assetCount: data.assetCount,
@ -48,22 +72,37 @@ export async function createDeliverable(
},
});
// Create a stage for each template
const stageData = templates.map((template) => {
// A stage is BLOCKED if it has any prerequisites
// Otherwise it starts as NOT_STARTED
const hasPrerequisites = template.dependsOn.length > 0;
const initialStatus: StageStatus = hasPrerequisites
? "BLOCKED"
: "NOT_STARTED";
return {
deliverableId: deliverable.id,
templateId: template.id,
status: initialStatus,
dueDate: data.dueDate ? new Date(data.dueDate) : null,
};
});
// Create stages from either dynamic pipeline or global templates
const stageData = useDynamic
? dynamicStages.map((def) => {
const hasPrerequisites = def.dependsOn.length > 0;
const initialStatus: StageStatus = hasPrerequisites
? "BLOCKED"
: "NOT_STARTED";
// Map to global template by slug for backward compat FK
const globalMatch = globalBySlug.get(def.slug);
return {
deliverableId: deliverable.id,
templateId: globalMatch?.id ?? globalTemplates[0]?.id,
stageDefinitionId: def.id,
status: initialStatus,
dueDate: data.dueDate ? new Date(data.dueDate) : null,
organizationId: project.organizationId,
};
}).filter((s) => s.templateId)
: globalTemplates.map((template) => {
const hasPrerequisites = template.dependsOn.length > 0;
const initialStatus: StageStatus = hasPrerequisites
? "BLOCKED"
: "NOT_STARTED";
return {
deliverableId: deliverable.id,
templateId: template.id,
status: initialStatus,
dueDate: data.dueDate ? new Date(data.dueDate) : null,
organizationId: project.organizationId,
};
});
await tx.deliverableStage.createMany({ data: stageData });
@ -72,7 +111,7 @@ export async function createDeliverable(
where: { id: deliverable.id },
include: {
stages: {
include: { template: true },
include: { template: true, stageDefinition: true },
orderBy: { template: { order: "asc" } },
},
},
@ -202,10 +241,10 @@ export async function bulkCreateDeliverables(
return { total: 0, created: [], failed: [] };
}
// Validate project exists
// Validate project exists and get its org
const project = await prisma.project.findUnique({
where: { id: projectId },
select: { id: true },
select: { id: true, organizationId: true },
});
if (!project) {
return {
@ -259,6 +298,7 @@ export async function bulkCreateDeliverables(
priority: item.priority || "MEDIUM",
notes: item.notes,
projectId,
organizationId: project.organizationId,
dueDate: item.dueDate ? new Date(item.dueDate) : null,
cmfSku: item.cmfSku,
assetCount: item.assetCount,
@ -281,6 +321,7 @@ export async function bulkCreateDeliverables(
templateId: template.id,
status: initialStatus,
dueDate: item.dueDate ? new Date(item.dueDate) : null,
organizationId: project.organizationId,
};
});

View file

@ -0,0 +1,112 @@
import { prisma } from "@/lib/prisma";
import type { Role } from "@/generated/prisma/client";
import { addDays } from "date-fns";
const INVITATION_EXPIRY_DAYS = 7;
export async function createInvitation(
email: string,
role: Role,
organizationId: string,
invitedById: string
) {
// Check if user already in org
const existingUser = await prisma.user.findFirst({
where: { email, organizationId },
});
if (existingUser) {
throw new Error("User is already a member of this organization");
}
// Check for existing pending invitation
const existingInvite = await prisma.invitation.findUnique({
where: { email_organizationId: { email, organizationId } },
});
if (existingInvite && !existingInvite.acceptedAt) {
// Update existing invitation (refresh token/expiry)
return prisma.invitation.update({
where: { id: existingInvite.id },
data: {
role,
invitedById,
expiresAt: addDays(new Date(), INVITATION_EXPIRY_DAYS),
},
include: {
invitedBy: { select: { name: true, email: true } },
organization: { select: { name: true } },
},
});
}
return prisma.invitation.create({
data: {
email,
role,
organizationId,
invitedById,
expiresAt: addDays(new Date(), INVITATION_EXPIRY_DAYS),
},
include: {
invitedBy: { select: { name: true, email: true } },
organization: { select: { name: true } },
},
});
}
export async function listInvitations(organizationId: string) {
return prisma.invitation.findMany({
where: { organizationId },
include: {
invitedBy: { select: { name: true, email: true } },
},
orderBy: { createdAt: "desc" },
});
}
export async function revokeInvitation(id: string, organizationId: string) {
return prisma.invitation.delete({
where: { id, organizationId },
});
}
export async function acceptInvitation(token: string, userId: string) {
const invitation = await prisma.invitation.findUnique({
where: { token },
include: { organization: true },
});
if (!invitation) throw new Error("Invitation not found");
if (invitation.acceptedAt) throw new Error("Invitation already accepted");
if (invitation.expiresAt < new Date()) throw new Error("Invitation has expired");
// Update user's org and role, mark invitation accepted
await prisma.$transaction([
prisma.user.update({
where: { id: userId },
data: {
organizationId: invitation.organizationId,
role: invitation.role,
},
}),
prisma.invitation.update({
where: { id: invitation.id },
data: { acceptedAt: new Date() },
}),
]);
return {
organizationId: invitation.organizationId,
organizationName: invitation.organization.name,
role: invitation.role,
};
}
export async function getInvitationByToken(token: string) {
return prisma.invitation.findUnique({
where: { token },
include: {
organization: { select: { name: true } },
invitedBy: { select: { name: true } },
},
});
}

View file

@ -0,0 +1,65 @@
import { prisma } from "@/lib/prisma";
import type {
CreateNotificationRuleInput,
UpdateNotificationRuleInput,
} from "@/lib/validators/notification-rule";
/**
* List all notification rules for an organization.
*/
export async function listNotificationRules(orgId: string) {
return prisma.notificationRule.findMany({
where: { organizationId: orgId },
orderBy: { createdAt: "desc" },
});
}
/**
* Create a new notification rule.
*/
export async function createNotificationRule(
data: CreateNotificationRuleInput,
orgId: string
) {
return prisma.notificationRule.create({
data: {
organizationId: orgId,
name: data.name,
event: data.event,
conditions: data.conditions ?? undefined,
channels: data.channels,
recipientRoles: data.recipientRoles,
isEnabled: data.isEnabled ?? true,
},
});
}
/**
* Update an existing notification rule (verifies org ownership).
*/
export async function updateNotificationRule(
id: string,
data: UpdateNotificationRuleInput,
orgId: string
) {
return prisma.notificationRule.update({
where: { id, organizationId: orgId },
data: {
...(data.name !== undefined && { name: data.name }),
...(data.event !== undefined && { event: data.event }),
...(data.conditions !== undefined && { conditions: data.conditions ?? undefined }),
...(data.channels !== undefined && { channels: data.channels }),
...(data.recipientRoles !== undefined && { recipientRoles: data.recipientRoles }),
...(data.isEnabled !== undefined && { isEnabled: data.isEnabled }),
},
});
}
/**
* Delete a notification rule (verifies org ownership).
*/
export async function deleteNotificationRule(id: string, orgId: string) {
return prisma.notificationRule.delete({
where: { id, organizationId: orgId },
});
}

View file

@ -0,0 +1,389 @@
import { prisma } from "@/lib/prisma";
import type {
CreatePipelineTemplateInput,
UpdatePipelineTemplateInput,
AddStageInput,
UpdateStageDefInput,
AddDependencyInput,
} from "@/lib/validators/pipeline-template";
// ─── Pipeline Template CRUD ─────────────────────────────
export async function listPipelineTemplates(orgId: string) {
return prisma.pipelineTemplate.findMany({
where: { organizationId: orgId, isArchived: false },
include: {
stages: { orderBy: { order: "asc" } },
_count: { select: { projects: true } },
},
orderBy: { createdAt: "desc" },
});
}
export async function getPipelineTemplate(id: string, orgId: string) {
return prisma.pipelineTemplate.findFirst({
where: { id, organizationId: orgId },
include: {
stages: {
orderBy: { order: "asc" },
include: {
dependsOn: { include: { prerequisite: true } },
dependedBy: { include: { stage: true } },
},
},
_count: { select: { projects: true } },
},
});
}
export async function createPipelineTemplate(
data: CreatePipelineTemplateInput,
orgId: string
) {
return prisma.pipelineTemplate.create({
data: {
name: data.name,
description: data.description,
organizationId: orgId,
},
include: { stages: true },
});
}
export async function updatePipelineTemplate(
id: string,
data: UpdatePipelineTemplateInput,
orgId: string
) {
// If setting as default, unset other defaults first
if (data.isDefault) {
await prisma.pipelineTemplate.updateMany({
where: { organizationId: orgId, isDefault: true },
data: { isDefault: false },
});
}
return prisma.pipelineTemplate.update({
where: { id, organizationId: orgId },
data,
include: { stages: true },
});
}
export async function archivePipelineTemplate(id: string, orgId: string) {
return prisma.pipelineTemplate.update({
where: { id, organizationId: orgId },
data: { isArchived: true, isDefault: false },
});
}
export async function duplicatePipelineTemplate(
id: string,
newName: string,
orgId: string
) {
const source = await prisma.pipelineTemplate.findFirst({
where: { id, organizationId: orgId },
include: {
stages: {
include: { dependsOn: true },
orderBy: { order: "asc" },
},
},
});
if (!source) throw new Error("Pipeline template not found");
return prisma.$transaction(async (tx) => {
const newPipeline = await tx.pipelineTemplate.create({
data: {
name: newName,
description: source.description,
organizationId: orgId,
},
});
// Copy stages
const oldToNewStageId = new Map<string, string>();
for (const stage of source.stages) {
const newStage = await tx.pipelineStageDefinition.create({
data: {
pipelineId: newPipeline.id,
name: stage.name,
slug: stage.slug,
order: stage.order,
isCriticalGate: stage.isCriticalGate,
isOptional: stage.isOptional,
description: stage.description,
estimatedDays: stage.estimatedDays,
color: stage.color,
customStatuses: stage.customStatuses ?? undefined,
},
});
oldToNewStageId.set(stage.id, newStage.id);
}
// Copy dependencies
for (const stage of source.stages) {
for (const dep of stage.dependsOn) {
const newStageId = oldToNewStageId.get(dep.stageId);
const newPrereqId = oldToNewStageId.get(dep.prerequisiteId);
if (newStageId && newPrereqId) {
await tx.pipelineStageDependencyV2.create({
data: { stageId: newStageId, prerequisiteId: newPrereqId },
});
}
}
}
return tx.pipelineTemplate.findUnique({
where: { id: newPipeline.id },
include: { stages: { orderBy: { order: "asc" } } },
});
});
}
// ─── Stage CRUD ─────────────────────────────────────────
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
export async function addStage(pipelineId: string, data: AddStageInput, orgId: string) {
// Verify pipeline belongs to org
const pipeline = await prisma.pipelineTemplate.findFirst({
where: { id: pipelineId, organizationId: orgId },
});
if (!pipeline) throw new Error("Pipeline template not found");
return prisma.pipelineStageDefinition.create({
data: {
pipelineId,
name: data.name,
slug: data.slug || slugify(data.name),
order: data.order,
isCriticalGate: data.isCriticalGate ?? false,
isOptional: data.isOptional ?? false,
description: data.description,
estimatedDays: data.estimatedDays,
color: data.color,
customStatuses: data.customStatuses ?? undefined,
},
});
}
export async function updateStage(
pipelineId: string,
stageId: string,
data: UpdateStageDefInput,
orgId: string
) {
const pipeline = await prisma.pipelineTemplate.findFirst({
where: { id: pipelineId, organizationId: orgId },
});
if (!pipeline) throw new Error("Pipeline template not found");
return prisma.pipelineStageDefinition.update({
where: { id: stageId, pipelineId },
data: {
...data,
customStatuses: data.customStatuses ?? undefined,
},
});
}
export async function removeStage(pipelineId: string, stageId: string, orgId: string) {
const pipeline = await prisma.pipelineTemplate.findFirst({
where: { id: pipelineId, organizationId: orgId },
});
if (!pipeline) throw new Error("Pipeline template not found");
return prisma.pipelineStageDefinition.delete({
where: { id: stageId, pipelineId },
});
}
export async function reorderStages(
pipelineId: string,
stageIds: string[],
orgId: string
) {
const pipeline = await prisma.pipelineTemplate.findFirst({
where: { id: pipelineId, organizationId: orgId },
});
if (!pipeline) throw new Error("Pipeline template not found");
await prisma.$transaction(
stageIds.map((id, index) =>
prisma.pipelineStageDefinition.update({
where: { id, pipelineId },
data: { order: index + 1 },
})
)
);
return prisma.pipelineStageDefinition.findMany({
where: { pipelineId },
orderBy: { order: "asc" },
});
}
// ─── Dependencies ───────────────────────────────────────
export async function addDependency(
pipelineId: string,
data: AddDependencyInput,
orgId: string
) {
const pipeline = await prisma.pipelineTemplate.findFirst({
where: { id: pipelineId, organizationId: orgId },
});
if (!pipeline) throw new Error("Pipeline template not found");
// Self-dependency check
if (data.stageId === data.prerequisiteId) {
throw new Error("A stage cannot depend on itself");
}
// Verify both stages belong to this pipeline
const stages = await prisma.pipelineStageDefinition.findMany({
where: { pipelineId, id: { in: [data.stageId, data.prerequisiteId] } },
});
if (stages.length !== 2) {
throw new Error("Both stages must belong to this pipeline");
}
// Cycle detection: walk prerequisite chain from prerequisiteId
// to see if we'd reach stageId (creating a cycle)
const visited = new Set<string>();
const hasCycle = await detectCycle(pipelineId, data.prerequisiteId, data.stageId, visited);
if (hasCycle) {
throw new Error("Adding this dependency would create a cycle");
}
return prisma.pipelineStageDependencyV2.create({
data: {
stageId: data.stageId,
prerequisiteId: data.prerequisiteId,
},
include: { stage: true, prerequisite: true },
});
}
async function detectCycle(
pipelineId: string,
currentId: string,
targetId: string,
visited: Set<string>
): Promise<boolean> {
if (currentId === targetId) return true;
if (visited.has(currentId)) return false;
visited.add(currentId);
// Get what currentId depends on
const deps = await prisma.pipelineStageDependencyV2.findMany({
where: {
stageId: currentId,
stage: { pipelineId },
},
select: { prerequisiteId: true },
});
for (const dep of deps) {
if (await detectCycle(pipelineId, dep.prerequisiteId, targetId, visited)) {
return true;
}
}
return false;
}
export async function removeDependency(
pipelineId: string,
stageId: string,
prerequisiteId: string,
orgId: string
) {
const pipeline = await prisma.pipelineTemplate.findFirst({
where: { id: pipelineId, organizationId: orgId },
});
if (!pipeline) throw new Error("Pipeline template not found");
return prisma.pipelineStageDependencyV2.delete({
where: {
stageId_prerequisiteId: { stageId, prerequisiteId },
},
});
}
// ─── Validation ─────────────────────────────────────────
export interface PipelineValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
}
export async function validatePipeline(
pipelineId: string,
orgId: string
): Promise<PipelineValidationResult> {
const pipeline = await getPipelineTemplate(pipelineId, orgId);
if (!pipeline) {
return { valid: false, errors: ["Pipeline not found"], warnings: [] };
}
const errors: string[] = [];
const warnings: string[] = [];
// Must have at least one stage
if (pipeline.stages.length === 0) {
errors.push("Pipeline must have at least one stage");
}
// Check for orphaned stages (stages that depend on non-existent stages)
const stageIds = new Set(pipeline.stages.map((s) => s.id));
for (const stage of pipeline.stages) {
for (const dep of stage.dependsOn) {
if (!stageIds.has(dep.prerequisiteId)) {
errors.push(`Stage "${stage.name}" depends on a non-existent stage`);
}
}
}
// Check for root stages (at least one stage with no prerequisites)
const hasRoot = pipeline.stages.some((s) => s.dependsOn.length === 0);
if (!hasRoot && pipeline.stages.length > 0) {
errors.push("Pipeline has no starting stage (every stage has prerequisites)");
}
// Check for duplicate orders
const orders = pipeline.stages.map((s) => s.order);
const uniqueOrders = new Set(orders);
if (uniqueOrders.size !== orders.length) {
warnings.push("Some stages have duplicate order values");
}
// Check for stages with no downstream dependents (leaf stages) — just a warning
const dependedOnIds = new Set<string>();
for (const stage of pipeline.stages) {
for (const dep of stage.dependsOn) {
dependedOnIds.add(dep.prerequisiteId);
}
}
const leafStages = pipeline.stages.filter(
(s) => !dependedOnIds.has(s.id) && s.dependsOn.length > 0
);
if (leafStages.length === 0 && pipeline.stages.length > 1) {
warnings.push("No terminal stages found — pipeline may be incomplete");
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}

View file

@ -0,0 +1,103 @@
import type { Role, Permission } from "@/generated/prisma/client";
import { prisma } from "@/lib/prisma";
import { DEFAULT_PERMISSIONS } from "@/lib/rbac/permissions";
/**
* Get the effective permissions for all roles in an organization.
* Returns org-specific overrides if they exist, otherwise defaults.
*/
export async function getOrgPermissions(organizationId: string) {
const orgPerms = await prisma.orgRolePermission.findMany({
where: { organizationId },
select: { role: true, permission: true },
});
// If org has custom permissions, use those
if (orgPerms.length > 0) {
const result: Record<string, string[]> = {
ADMIN: [],
PRODUCER: [],
ARTIST: [],
};
for (const p of orgPerms) {
result[p.role].push(p.permission);
}
return result;
}
// Otherwise return defaults
return DEFAULT_PERMISSIONS as Record<string, string[]>;
}
/**
* Set the permissions for a specific role in an organization.
* Replaces all existing permissions for that role.
*/
export async function updateRolePermissions(
organizationId: string,
role: Role,
permissions: Permission[]
) {
// Delete existing permissions for this role in this org
await prisma.orgRolePermission.deleteMany({
where: { organizationId, role },
});
// Insert new permissions
if (permissions.length > 0) {
await prisma.orgRolePermission.createMany({
data: permissions.map((permission) => ({
organizationId,
role,
permission,
})),
});
}
// Ensure all roles have entries (so the system knows this org uses custom permissions)
// If only updating one role, make sure other roles also have entries
const otherRoles = (["ADMIN", "PRODUCER", "ARTIST"] as Role[]).filter(
(r) => r !== role
);
for (const otherRole of otherRoles) {
const count = await prisma.orgRolePermission.count({
where: { organizationId, role: otherRole },
});
if (count === 0) {
// Seed from defaults
const defaults = DEFAULT_PERMISSIONS[otherRole];
await prisma.orgRolePermission.createMany({
data: defaults.map((permission) => ({
organizationId,
role: otherRole,
permission,
})),
});
}
}
return getOrgPermissions(organizationId);
}
/**
* Seed default permissions for an organization.
*/
export async function seedDefaultPermissions(organizationId: string) {
const existing = await prisma.orgRolePermission.count({
where: { organizationId },
});
if (existing > 0) return; // Already seeded
const data: { organizationId: string; role: Role; permission: Permission }[] = [];
for (const [role, permissions] of Object.entries(DEFAULT_PERMISSIONS)) {
for (const permission of permissions) {
data.push({
organizationId,
role: role as Role,
permission: permission as Permission,
});
}
}
await prisma.orgRolePermission.createMany({ data });
}

View file

@ -0,0 +1,21 @@
import { z } from "zod/v4";
export const createCustomFieldSchema = z.object({
entityType: z.enum(["PROJECT", "DELIVERABLE"]),
fieldName: z.string().min(1).max(100),
fieldType: z.enum(["TEXT", "NUMBER", "DATE", "SELECT", "BOOLEAN"]),
fieldOptions: z.object({ options: z.array(z.string()) }).optional(),
isRequired: z.boolean().optional(),
order: z.number().int().min(0).optional(),
});
export const updateCustomFieldSchema = z.object({
fieldName: z.string().min(1).max(100).optional(),
fieldType: z.enum(["TEXT", "NUMBER", "DATE", "SELECT", "BOOLEAN"]).optional(),
fieldOptions: z.object({ options: z.array(z.string()) }).nullable().optional(),
isRequired: z.boolean().optional(),
order: z.number().int().min(0).optional(),
});
export type CreateCustomFieldInput = z.infer<typeof createCustomFieldSchema>;
export type UpdateCustomFieldInput = z.infer<typeof updateCustomFieldSchema>;

View file

@ -0,0 +1,13 @@
import { z } from "zod/v4";
export const createInvitationSchema = z.object({
email: z.email(),
role: z.enum(["ADMIN", "PRODUCER", "ARTIST"]).optional(),
});
export const acceptInvitationSchema = z.object({
token: z.string().min(1),
});
export type CreateInvitationInput = z.infer<typeof createInvitationSchema>;
export type AcceptInvitationInput = z.infer<typeof acceptInvitationSchema>;

View file

@ -0,0 +1,46 @@
import { z } from "zod/v4";
export const createNotificationRuleSchema = z.object({
name: z.string().min(1).max(100),
event: z.enum([
"STAGE_STATUS_CHANGE",
"DEADLINE_APPROACHING",
"DEADLINE_OVERDUE",
"REVISION_SUBMITTED",
"REVISION_FEEDBACK",
"COMMENT_ADDED",
"ASSIGNMENT_CHANGE",
]),
conditions: z.array(z.object({
field: z.string(),
operator: z.enum(["equals", "not_equals", "contains", "in"]),
value: z.any(),
})).optional(),
channels: z.array(z.enum(["IN_APP", "EMAIL"])),
recipientRoles: z.array(z.string()),
isEnabled: z.boolean().optional(),
});
export const updateNotificationRuleSchema = z.object({
name: z.string().min(1).max(100).optional(),
event: z.enum([
"STAGE_STATUS_CHANGE",
"DEADLINE_APPROACHING",
"DEADLINE_OVERDUE",
"REVISION_SUBMITTED",
"REVISION_FEEDBACK",
"COMMENT_ADDED",
"ASSIGNMENT_CHANGE",
]).optional(),
conditions: z.array(z.object({
field: z.string(),
operator: z.enum(["equals", "not_equals", "contains", "in"]),
value: z.any(),
})).nullable().optional(),
channels: z.array(z.enum(["IN_APP", "EMAIL"])).optional(),
recipientRoles: z.array(z.string()).optional(),
isEnabled: z.boolean().optional(),
});
export type CreateNotificationRuleInput = z.infer<typeof createNotificationRuleSchema>;
export type UpdateNotificationRuleInput = z.infer<typeof updateNotificationRuleSchema>;

View file

@ -0,0 +1,20 @@
import { z } from "zod/v4";
const ROLES = ["ADMIN", "PRODUCER", "ARTIST"] as const;
const PERMISSIONS = [
"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",
] as const;
export const updatePermissionsSchema = z.object({
role: z.enum(ROLES),
permissions: z.array(z.enum(PERMISSIONS)),
});
export type UpdatePermissionsInput = z.infer<typeof updatePermissionsSchema>;

View file

@ -0,0 +1,51 @@
import { z } from "zod/v4";
export const createPipelineTemplateSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
});
export const updatePipelineTemplateSchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).nullable().optional(),
isDefault: z.boolean().optional(),
});
export const addStageSchema = z.object({
name: z.string().min(1).max(100),
slug: z.string().min(1).max(100).optional(),
order: z.number().int().min(1),
isCriticalGate: z.boolean().optional(),
isOptional: z.boolean().optional(),
description: z.string().max(500).nullable().optional(),
estimatedDays: z.number().positive().nullable().optional(),
color: z.string().max(20).nullable().optional(),
customStatuses: z.any().optional(),
});
export const updateStageDefSchema = z.object({
name: z.string().min(1).max(100).optional(),
slug: z.string().min(1).max(100).optional(),
description: z.string().max(500).nullable().optional(),
isCriticalGate: z.boolean().optional(),
isOptional: z.boolean().optional(),
estimatedDays: z.number().positive().nullable().optional(),
color: z.string().max(20).nullable().optional(),
customStatuses: z.any().optional(),
});
export const reorderStagesSchema = z.object({
stageIds: z.array(z.string()),
});
export const addDependencySchema = z.object({
stageId: z.string(),
prerequisiteId: z.string(),
});
export type CreatePipelineTemplateInput = z.infer<typeof createPipelineTemplateSchema>;
export type UpdatePipelineTemplateInput = z.infer<typeof updatePipelineTemplateSchema>;
export type AddStageInput = z.infer<typeof addStageSchema>;
export type UpdateStageDefInput = z.infer<typeof updateStageDefSchema>;
export type ReorderStagesInput = z.infer<typeof reorderStagesSchema>;
export type AddDependencyInput = z.infer<typeof addDependencySchema>;

View file

@ -0,0 +1,29 @@
import { create } from "zustand";
interface StageEdit {
id: string;
name: string;
slug: string;
order: number;
isCriticalGate: boolean;
isOptional: boolean;
description: string | null;
estimatedDays: number | null;
color: string | null;
}
interface PipelineBuilderState {
selectedStageId: string | null;
isDirty: boolean;
selectStage: (id: string | null) => void;
setDirty: (dirty: boolean) => void;
reset: () => void;
}
export const usePipelineBuilderStore = create<PipelineBuilderState>()((set) => ({
selectedStageId: null,
isDirty: false,
selectStage: (id) => set({ selectedStageId: id }),
setDirty: (dirty) => set({ isDirty: dirty }),
reset: () => set({ selectedStageId: null, isDirty: false }),
}));