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:
parent
9d0677419d
commit
40028b7ced
89 changed files with 5800 additions and 170 deletions
167
package-lock.json
generated
167
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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!");
|
||||
}
|
||||
|
||||
|
|
|
|||
99
scripts/backfill-org-ids.ts
Normal file
99
scripts/backfill-org-ids.ts
Normal 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();
|
||||
});
|
||||
181
scripts/migrate-to-dynamic-pipelines.ts
Normal file
181
scripts/migrate-to-dynamic-pipelines.ts
Normal 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();
|
||||
});
|
||||
328
src/app/(app)/settings/fields/page.tsx
Normal file
328
src/app/(app)/settings/fields/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
391
src/app/(app)/settings/notifications/page.tsx
Normal file
391
src/app/(app)/settings/notifications/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
265
src/app/(app)/settings/permissions/page.tsx
Normal file
265
src/app/(app)/settings/permissions/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
273
src/app/(app)/settings/pipelines/[pipelineId]/page.tsx
Normal file
273
src/app/(app)/settings/pipelines/[pipelineId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
256
src/app/(app)/settings/pipelines/page.tsx
Normal file
256
src/app/(app)/settings/pipelines/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
271
src/app/(app)/settings/team/page.tsx
Normal file
271
src/app/(app)/settings/team/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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: [] });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
23
src/app/api/invitations/accept/route.ts
Normal file
23
src/app/api/invitations/accept/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
52
src/app/api/org/fields/[fieldId]/route.ts
Normal file
52
src/app/api/org/fields/[fieldId]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
47
src/app/api/org/fields/route.ts
Normal file
47
src/app/api/org/fields/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
20
src/app/api/org/invitations/[id]/route.ts
Normal file
20
src/app/api/org/invitations/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
38
src/app/api/org/invitations/route.ts
Normal file
38
src/app/api/org/invitations/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
48
src/app/api/org/notification-rules/[ruleId]/route.ts
Normal file
48
src/app/api/org/notification-rules/[ruleId]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
39
src/app/api/org/notification-rules/route.ts
Normal file
39
src/app/api/org/notification-rules/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
44
src/app/api/org/permissions/route.ts
Normal file
44
src/app/api/org/permissions/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
45
src/app/api/pipelines/[pipelineId]/dependencies/route.ts
Normal file
45
src/app/api/pipelines/[pipelineId]/dependencies/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
24
src/app/api/pipelines/[pipelineId]/duplicate/route.ts
Normal file
24
src/app/api/pipelines/[pipelineId]/duplicate/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
61
src/app/api/pipelines/[pipelineId]/route.ts
Normal file
61
src/app/api/pipelines/[pipelineId]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
40
src/app/api/pipelines/[pipelineId]/stages/[stageId]/route.ts
Normal file
40
src/app/api/pipelines/[pipelineId]/stages/[stageId]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
24
src/app/api/pipelines/[pipelineId]/stages/reorder/route.ts
Normal file
24
src/app/api/pipelines/[pipelineId]/stages/reorder/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
24
src/app/api/pipelines/[pipelineId]/stages/route.ts
Normal file
24
src/app/api/pipelines/[pipelineId]/stages/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
20
src/app/api/pipelines/[pipelineId]/validate/route.ts
Normal file
20
src/app/api/pipelines/[pipelineId]/validate/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
32
src/app/api/pipelines/route.ts
Normal file
32
src/app/api/pipelines/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
83
src/components/pipeline-builder/dependency-edge.tsx
Normal file
83
src/components/pipeline-builder/dependency-edge.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
179
src/components/pipeline-builder/pipeline-graph.tsx
Normal file
179
src/components/pipeline-builder/pipeline-graph.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
151
src/components/pipeline-builder/pipeline-stage-list.tsx
Normal file
151
src/components/pipeline-builder/pipeline-stage-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
275
src/components/pipeline-builder/stage-edit-sheet.tsx
Normal file
275
src/components/pipeline-builder/stage-edit-sheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
src/components/pipeline-builder/stage-node.tsx
Normal file
71
src/components/pipeline-builder/stage-node.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
src/hooks/use-custom-fields.ts
Normal file
83
src/hooks/use-custom-fields.ts
Normal 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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
45
src/hooks/use-invitations.ts
Normal file
45
src/hooks/use-invitations.ts
Normal 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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
78
src/hooks/use-notification-rules.ts
Normal file
78
src/hooks/use-notification-rules.ts
Normal 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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
35
src/hooks/use-permissions.ts
Normal file
35
src/hooks/use-permissions.ts
Normal 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
170
src/hooks/use-pipelines.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
92
src/lib/pipeline/stage-resolver.ts
Normal file
92
src/lib/pipeline/stage-resolver.ts
Normal 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
85
src/lib/rbac/org-scope.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
82
src/lib/rbac/permissions.ts
Normal file
82
src/lib/rbac/permissions.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
63
src/lib/rbac/require-auth.ts
Normal file
63
src/lib/rbac/require-auth.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
73
src/lib/services/custom-field-service.ts
Normal file
73
src/lib/services/custom-field-service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
112
src/lib/services/invitation-service.ts
Normal file
112
src/lib/services/invitation-service.ts
Normal 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 } },
|
||||
},
|
||||
});
|
||||
}
|
||||
65
src/lib/services/notification-rule-service.ts
Normal file
65
src/lib/services/notification-rule-service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
389
src/lib/services/pipeline-template-service.ts
Normal file
389
src/lib/services/pipeline-template-service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
103
src/lib/services/rbac-service.ts
Normal file
103
src/lib/services/rbac-service.ts
Normal 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 });
|
||||
}
|
||||
21
src/lib/validators/custom-field.ts
Normal file
21
src/lib/validators/custom-field.ts
Normal 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>;
|
||||
13
src/lib/validators/invitation.ts
Normal file
13
src/lib/validators/invitation.ts
Normal 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>;
|
||||
46
src/lib/validators/notification-rule.ts
Normal file
46
src/lib/validators/notification-rule.ts
Normal 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>;
|
||||
20
src/lib/validators/permissions.ts
Normal file
20
src/lib/validators/permissions.ts
Normal 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>;
|
||||
51
src/lib/validators/pipeline-template.ts
Normal file
51
src/lib/validators/pipeline-template.ts
Normal 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>;
|
||||
29
src/stores/pipeline-builder-store.ts
Normal file
29
src/stores/pipeline-builder-store.ts
Normal 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 }),
|
||||
}));
|
||||
Loading…
Add table
Reference in a new issue