diff --git a/apps/api/src/admin-organizations/admin-policies.controller.ts b/apps/api/src/admin-organizations/admin-policies.controller.ts index c3d56fa8e0..c5f369053a 100644 --- a/apps/api/src/admin-organizations/admin-policies.controller.ts +++ b/apps/api/src/admin-organizations/admin-policies.controller.ts @@ -17,7 +17,7 @@ import { db } from '@db'; import { PolicyStatus, Frequency, - Departments, + DEPARTMENT_MAX_LENGTH, } from '../policies/dto/create-policy.dto'; import { auth as triggerAuth, tasks } from '@trigger.dev/sdk'; import type { updatePolicy } from '../trigger/policies/update-policy'; @@ -89,14 +89,19 @@ export class AdminPoliciesController { } if (body.department !== undefined) { - if ( - !Object.values(Departments).includes(body.department as Departments) - ) { + if (typeof body.department !== 'string') { + throw new BadRequestException('department must be a string'); + } + const trimmed = body.department.trim(); + if (trimmed.length === 0) { + throw new BadRequestException('department must not be empty'); + } + if (trimmed.length > DEPARTMENT_MAX_LENGTH) { throw new BadRequestException( - `Invalid department. Must be one of: ${Object.values(Departments).join(', ')}`, + `department must be at most ${DEPARTMENT_MAX_LENGTH} characters`, ); } - updateData.department = body.department as Departments; + updateData.department = trimmed; } if (body.frequency !== undefined) { diff --git a/apps/api/src/admin-organizations/dto/create-admin-policy.dto.ts b/apps/api/src/admin-organizations/dto/create-admin-policy.dto.ts index 61443ef8b7..2b12fa1440 100644 --- a/apps/api/src/admin-organizations/dto/create-admin-policy.dto.ts +++ b/apps/api/src/admin-organizations/dto/create-admin-policy.dto.ts @@ -1,9 +1,17 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsString, IsNotEmpty, IsOptional, IsEnum } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { + IsString, + IsNotEmpty, + IsOptional, + IsEnum, + MaxLength, +} from 'class-validator'; import { PolicyStatus, Frequency, Departments, + DEPARTMENT_MAX_LENGTH, } from '../../policies/dto/create-policy.dto'; export class CreateAdminPolicyDto { @@ -45,12 +53,17 @@ export class CreateAdminPolicyDto { frequency?: Frequency; @ApiProperty({ - description: 'Department this policy applies to', - enum: Departments, + description: + 'Department this policy applies to. Built-in values: none, admin, gov, hr, it, itsm, qms. Custom department names are also accepted.', example: Departments.IT, required: false, + type: 'string', + maxLength: DEPARTMENT_MAX_LENGTH, }) @IsOptional() - @IsEnum(Departments) - department?: Departments; + @IsString() + @IsNotEmpty() + @MaxLength(DEPARTMENT_MAX_LENGTH) + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + department?: string; } diff --git a/apps/api/src/auth/types.ts b/apps/api/src/auth/types.ts index a7a416ad01..bf3c11093f 100644 --- a/apps/api/src/auth/types.ts +++ b/apps/api/src/auth/types.ts @@ -1,7 +1,5 @@ // Types for API authentication - supports API keys and session-based auth -import { Departments } from '@db'; - export interface AuthenticatedRequest extends Request { organizationId: string; authType: 'api-key' | 'session' | 'service'; @@ -13,7 +11,7 @@ export interface AuthenticatedRequest extends Request { userEmail?: string; userRoles: string[] | null; memberId?: string; // Member ID for assignment filtering (only available for session auth) - memberDepartment?: Departments; // Member department for visibility filtering (only available for session auth) + memberDepartment?: string; // Member department for visibility filtering (only available for session auth) apiKeyScopes?: string[]; // Scopes for API key auth (empty = legacy full access) apiKeyId?: string; // ApiKey row id — only set for API key auth. Used by ActingUserResolver / audit log attribution. apiKeyName?: string; // Human-readable API key name (e.g. "CI Pipeline") — only set for API key auth. @@ -34,7 +32,7 @@ export interface AuthContext { userEmail?: string; // Only available for session auth userRoles: string[] | null; memberId?: string; // Member ID for assignment filtering (only available for session auth) - memberDepartment?: Departments; // Member department for visibility filtering (only available for session auth) + memberDepartment?: string; // Member department for visibility filtering (only available for session auth) apiKeyScopes?: string[]; // Scopes for API key auth (empty = legacy full access) impersonatedBy?: string; // User ID of the admin who initiated impersonation (only set during impersonation sessions) } diff --git a/apps/api/src/people/dto/create-people.dto.ts b/apps/api/src/people/dto/create-people.dto.ts index 5149ae451d..ff78782f3e 100644 --- a/apps/api/src/people/dto/create-people.dto.ts +++ b/apps/api/src/people/dto/create-people.dto.ts @@ -1,13 +1,15 @@ import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; import { IsString, IsNotEmpty, IsOptional, - IsEnum, IsBoolean, IsNumber, + MaxLength, } from 'class-validator'; import { Departments } from '@db'; +import { DEPARTMENT_MAX_LENGTH } from '../../policies/dto/create-policy.dto'; export class CreatePeopleDto { @ApiProperty({ @@ -27,14 +29,19 @@ export class CreatePeopleDto { role: string; @ApiProperty({ - description: 'Member department', - enum: Departments, + description: + 'Member department. Built-in values: none, admin, gov, hr, it, itsm, qms. Custom department names are also accepted.', example: Departments.it, required: false, + type: 'string', + maxLength: DEPARTMENT_MAX_LENGTH, }) @IsOptional() - @IsEnum(Departments) - department?: Departments; + @IsString() + @IsNotEmpty() + @MaxLength(DEPARTMENT_MAX_LENGTH) + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + department?: string; @ApiProperty({ description: 'Whether member is active', diff --git a/apps/api/src/people/dto/people-responses.dto.ts b/apps/api/src/people/dto/people-responses.dto.ts index d48a2db556..5a346e0cd0 100644 --- a/apps/api/src/people/dto/people-responses.dto.ts +++ b/apps/api/src/people/dto/people-responses.dto.ts @@ -110,11 +110,12 @@ export class PeopleResponseDto { createdAt: Date; @ApiProperty({ - description: 'Member department', - enum: Departments, + description: + 'Member department. May be one of the built-in values (none, admin, gov, hr, it, itsm, qms) or a custom department name.', example: Departments.it, + type: 'string', }) - department: Departments; + department: string; @ApiProperty({ description: 'Job title for the member', diff --git a/apps/api/src/policies/dto/create-policy.dto.ts b/apps/api/src/policies/dto/create-policy.dto.ts index 0aa576e236..2a621b547b 100644 --- a/apps/api/src/policies/dto/create-policy.dto.ts +++ b/apps/api/src/policies/dto/create-policy.dto.ts @@ -8,6 +8,8 @@ import { IsArray, IsDateString, IsObject, + IsNotEmpty, + MaxLength, } from 'class-validator'; export enum PolicyStatus { @@ -22,6 +24,10 @@ export enum Frequency { YEARLY = 'yearly', } +/** + * Built-in department values. Organizations may also use custom department + * names — the `department` field accepts any non-empty string. + */ export enum Departments { NONE = 'none', ADMIN = 'admin', @@ -32,6 +38,8 @@ export enum Departments { QMS = 'qms', } +export const DEPARTMENT_MAX_LENGTH = 64; + export class CreatePolicyDto { @ApiProperty({ description: 'Name of the policy', @@ -96,14 +104,19 @@ export class CreatePolicyDto { frequency?: Frequency; @ApiProperty({ - description: 'Department this policy applies to', - enum: Departments, + description: + 'Department this policy applies to. Built-in values: none, admin, gov, hr, it, itsm, qms. Custom department names are also accepted.', example: Departments.IT, required: false, + type: 'string', + maxLength: DEPARTMENT_MAX_LENGTH, }) @IsOptional() - @IsEnum(Departments) - department?: Departments; + @IsString() + @IsNotEmpty() + @MaxLength(DEPARTMENT_MAX_LENGTH) + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + department?: string; @ApiProperty({ description: 'Whether this policy requires a signature', diff --git a/apps/api/src/policies/dto/policy-responses.dto.ts b/apps/api/src/policies/dto/policy-responses.dto.ts index 74c05b18f1..883a1c7335 100644 --- a/apps/api/src/policies/dto/policy-responses.dto.ts +++ b/apps/api/src/policies/dto/policy-responses.dto.ts @@ -1,6 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; import { PolicyStatus, Frequency, Departments } from './create-policy.dto'; +const DEPARTMENT_EXAMPLE = Departments.IT; + export class PolicyResponseDto { @ApiProperty({ description: 'The policy ID', @@ -61,12 +63,13 @@ export class PolicyResponseDto { frequency?: Frequency; @ApiProperty({ - description: 'Department this policy applies to', - enum: Departments, - example: Departments.IT, + description: + 'Department this policy applies to. May be one of the built-in values (none, admin, gov, hr, it, itsm, qms) or a custom department name.', + example: DEPARTMENT_EXAMPLE, nullable: true, + type: 'string', }) - department?: Departments; + department?: string; @ApiProperty({ description: 'Whether this policy requires a signature', diff --git a/apps/api/src/risks/dto/create-risk.dto.ts b/apps/api/src/risks/dto/create-risk.dto.ts index 874f7d4077..63999169b2 100644 --- a/apps/api/src/risks/dto/create-risk.dto.ts +++ b/apps/api/src/risks/dto/create-risk.dto.ts @@ -1,5 +1,12 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsString, IsNotEmpty, IsOptional, IsEnum } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { + IsString, + IsNotEmpty, + IsOptional, + IsEnum, + MaxLength, +} from 'class-validator'; import { RiskCategory, Departments, @@ -8,6 +15,7 @@ import { Impact, RiskTreatmentType, } from '@db'; +import { DEPARTMENT_MAX_LENGTH } from '../../policies/dto/create-policy.dto'; export class CreateRiskDto { @ApiProperty({ @@ -36,14 +44,19 @@ export class CreateRiskDto { category: RiskCategory; @ApiProperty({ - description: 'Department responsible for the risk', - enum: Departments, + description: + 'Department responsible for the risk. Built-in values: none, admin, gov, hr, it, itsm, qms. Custom department names are also accepted.', required: false, example: Departments.it, + type: 'string', + maxLength: DEPARTMENT_MAX_LENGTH, }) @IsOptional() - @IsEnum(Departments) - department?: Departments; + @IsString() + @IsNotEmpty() + @MaxLength(DEPARTMENT_MAX_LENGTH) + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + department?: string; @ApiProperty({ description: 'Current status of the risk', diff --git a/apps/api/src/risks/dto/get-risks-query.dto.ts b/apps/api/src/risks/dto/get-risks-query.dto.ts index 0ed01747df..0256f6d7bf 100644 --- a/apps/api/src/risks/dto/get-risks-query.dto.ts +++ b/apps/api/src/risks/dto/get-risks-query.dto.ts @@ -1,7 +1,17 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; -import { Type } from 'class-transformer'; +import { + IsEnum, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Max, + MaxLength, + Min, +} from 'class-validator'; +import { Transform, Type } from 'class-transformer'; import { RiskCategory, Departments, RiskStatus } from '@db'; +import { DEPARTMENT_MAX_LENGTH } from '../../policies/dto/create-policy.dto'; export enum RiskSortBy { CREATED_AT = 'createdAt', @@ -85,12 +95,18 @@ export class GetRisksQueryDto { category?: RiskCategory; @ApiPropertyOptional({ - description: 'Filter by department', - enum: Departments, + description: + 'Filter by department. Built-in values: none, admin, gov, hr, it, itsm, qms. Custom department names are also accepted.', + type: 'string', + example: Departments.it, + maxLength: DEPARTMENT_MAX_LENGTH, }) @IsOptional() - @IsEnum(Departments) - department?: Departments; + @IsString() + @IsNotEmpty() + @MaxLength(DEPARTMENT_MAX_LENGTH) + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + department?: string; @ApiPropertyOptional({ description: 'Filter by assignee member ID', diff --git a/apps/api/src/risks/dto/risk-response.dto.ts b/apps/api/src/risks/dto/risk-response.dto.ts index b5e647e291..2a62841ae1 100644 --- a/apps/api/src/risks/dto/risk-response.dto.ts +++ b/apps/api/src/risks/dto/risk-response.dto.ts @@ -36,12 +36,13 @@ export class RiskResponseDto { category: RiskCategory; @ApiProperty({ - description: 'Department responsible for the risk', - enum: Departments, + description: + 'Department responsible for the risk. May be one of the built-in values (none, admin, gov, hr, it, itsm, qms) or a custom department name.', nullable: true, example: Departments.it, + type: 'string', }) - department: Departments | null; + department: string | null; @ApiProperty({ description: 'Current status of the risk', diff --git a/apps/api/src/tasks/dto/swagger.dto.ts b/apps/api/src/tasks/dto/swagger.dto.ts index 76b21023ea..b0c7617d8d 100644 --- a/apps/api/src/tasks/dto/swagger.dto.ts +++ b/apps/api/src/tasks/dto/swagger.dto.ts @@ -38,10 +38,13 @@ export class CreateTaskDto { integrationScheduleFrequency?: string; @ApiProperty({ - description: 'Department assignment', - enum: ['none', 'admin', 'gov', 'hr', 'it', 'itsm', 'qms'], + description: + 'Department assignment. Built-in values: none, admin, gov, hr, it, itsm, qms. Custom department names are also accepted.', + type: 'string', + nullable: true, default: 'none', required: false, + maxLength: 64, }) department?: string; @@ -97,9 +100,11 @@ export class UpdateTaskDto { integrationScheduleFrequency?: string; @ApiProperty({ - description: 'Department assignment', - enum: ['none', 'admin', 'gov', 'hr', 'it', 'itsm', 'qms'], + description: + 'Department assignment. Built-in values: none, admin, gov, hr, it, itsm, qms. Custom department names are also accepted.', + type: 'string', required: false, + maxLength: 64, }) department?: string; @@ -143,9 +148,11 @@ export class TaskQueryDto { frequency?: string; @ApiProperty({ - description: 'Filter by department', - enum: ['none', 'admin', 'gov', 'hr', 'it', 'itsm', 'qms'], + description: + 'Filter by department. Built-in values: none, admin, gov, hr, it, itsm, qms. Custom department names are also accepted.', + type: 'string', required: false, + maxLength: 64, }) department?: string; @@ -201,8 +208,9 @@ export class TaskResponseDto { integrationLastRunAt: Date | null; @ApiProperty({ - description: 'Department assignment', - enum: ['none', 'admin', 'gov', 'hr', 'it', 'itsm', 'qms'], + description: + 'Department assignment. May be one of the built-in values (none, admin, gov, hr, it, itsm, qms) or a custom department name.', + type: 'string', nullable: true, }) department: string | null; diff --git a/apps/api/src/tasks/tasks.controller.ts b/apps/api/src/tasks/tasks.controller.ts index 0ef62a03cc..4bfe91f286 100644 --- a/apps/api/src/tasks/tasks.controller.ts +++ b/apps/api/src/tasks/tasks.controller.ts @@ -40,6 +40,31 @@ import { TaskResponseDto, } from './dto/task-responses.dto'; import { TasksService } from './tasks.service'; +import { DEPARTMENT_MAX_LENGTH } from '../policies/dto/create-policy.dto'; + +/** + * Normalises a free-form department value from a request body: trims whitespace, + * enforces the shared max length, and rejects empty strings (use `null` to clear). + */ +function normalizeDepartment( + value: string | null | undefined, +): string | null | undefined { + if (value === undefined) return undefined; + if (value === null) return null; + if (typeof value !== 'string') { + throw new BadRequestException('department must be a string'); + } + const trimmed = value.trim(); + if (trimmed.length === 0) { + throw new BadRequestException('department must not be empty (use null to clear)'); + } + if (trimmed.length > DEPARTMENT_MAX_LENGTH) { + throw new BadRequestException( + `department must be at most ${DEPARTMENT_MAX_LENGTH} characters`, + ); + } + return trimmed; +} @ApiTags('Tasks') @ApiExtraModels(TaskResponseDto, AttachmentResponseDto) @@ -180,9 +205,11 @@ export class TasksController { }, department: { type: 'string', - enum: ['none', 'admin', 'gov', 'hr', 'it', 'itsm', 'qms'], nullable: true, example: 'it', + maxLength: 64, + description: + 'Built-in values: none, admin, gov, hr, it, itsm, qms. Custom department names are also accepted.', }, controlIds: { type: 'array', @@ -235,7 +262,12 @@ export class TasksController { throw new BadRequestException('title and description are required'); } - return await this.tasksService.createTask(organizationId, body); + const department = normalizeDepartment(body.department); + + return await this.tasksService.createTask(organizationId, { + ...body, + ...(department !== undefined && { department }), + }); } @Patch('bulk') @@ -811,8 +843,10 @@ export class TasksController { }, department: { type: 'string', - enum: ['none', 'admin', 'gov', 'hr', 'it', 'itsm', 'qms'], example: 'it', + maxLength: 64, + description: + 'Built-in values: none, admin, gov, hr, it, itsm, qms. Custom department names are also accepted.', }, reviewDate: { type: 'string', @@ -858,7 +892,7 @@ export class TasksController { approverId?: string | null; frequency?: string; integrationScheduleFrequency?: string; - department?: string; + department?: string | null; reviewDate?: string; notRelevantJustification?: string; }, @@ -869,6 +903,8 @@ export class TasksController { 'User ID is required. Task updates require authenticated user session.', ); + const normalizedDepartment = normalizeDepartment(body.department); + let parsedReviewDate: Date | null | undefined; if (body.reviewDate !== undefined) { if (body.reviewDate === null) { @@ -897,7 +933,7 @@ export class TasksController { integrationScheduleFrequency: body.integrationScheduleFrequency as | TaskFrequency | undefined, - department: body.department, + department: normalizedDepartment, reviewDate: parsedReviewDate, notRelevantJustification: body.notRelevantJustification, }, diff --git a/apps/api/src/tasks/tasks.service.ts b/apps/api/src/tasks/tasks.service.ts index 44c6b01211..8be182dcf0 100644 --- a/apps/api/src/tasks/tasks.service.ts +++ b/apps/api/src/tasks/tasks.service.ts @@ -7,7 +7,7 @@ import { NotFoundException, } from '@nestjs/common'; import { filterDescriptionByFrameworks } from './description-framework-filter'; -import { db, TaskStatus, Prisma, TaskFrequency, Departments } from '@db'; +import { db, TaskStatus, Prisma, TaskFrequency } from '@db'; import { TaskResponseDto } from './dto/task-responses.dto'; import { TaskNotifierService } from './task-notifier.service'; import { checkAutoCompletePhases } from '../frameworks/frameworks-timeline.helper'; @@ -566,7 +566,7 @@ export class TasksService { approverId?: string | null; frequency?: TaskFrequency; integrationScheduleFrequency?: TaskFrequency; - department?: string; + department?: string | null; reviewDate?: Date | null; notRelevantJustification?: string; }, @@ -602,7 +602,7 @@ export class TasksService { approverId?: string | null; frequency?: TaskFrequency; integrationScheduleFrequency?: TaskFrequency; - department?: string; + department?: string | null; reviewDate?: Date | null; notRelevantJustification?: string | null; } = {}; @@ -871,7 +871,7 @@ export class TasksService { status: 'todo', order: 0, frequency: (createData.frequency as TaskFrequency) || null, - department: (createData.department as Departments) || null, + department: createData.department || null, automationStatus, taskTemplateId: createData.taskTemplateId || null, ...(createData.controlIds && diff --git a/apps/api/src/utils/department-visibility.ts b/apps/api/src/utils/department-visibility.ts index 973fd2396d..3c524239cf 100644 --- a/apps/api/src/utils/department-visibility.ts +++ b/apps/api/src/utils/department-visibility.ts @@ -1,4 +1,6 @@ -import { Departments, Prisma, PolicyVisibility } from '@db'; +import { Prisma, PolicyVisibility } from '@db'; + +const NONE_DEPARTMENT = 'none'; /** * Roles that have full access without department visibility filtering @@ -24,7 +26,7 @@ export function isPrivilegedRole(roles: string[] | null | undefined): boolean { * - See policies where their department is in visibleToDepartments */ export function buildPolicyVisibilityFilter( - memberDepartment: Departments | null | undefined, + memberDepartment: string | null | undefined, memberRoles: string[] | null | undefined, ): Prisma.PolicyWhereInput { // Privileged roles see everything @@ -33,7 +35,7 @@ export function buildPolicyVisibilityFilter( } // If no department, only show policies visible to ALL - if (!memberDepartment || memberDepartment === Departments.none) { + if (!memberDepartment || memberDepartment === NONE_DEPARTMENT) { return { visibility: PolicyVisibility.ALL, }; @@ -59,9 +61,9 @@ export function buildPolicyVisibilityFilter( export function canViewPolicy( policy: { visibility: PolicyVisibility; - visibleToDepartments: Departments[]; + visibleToDepartments: string[]; }, - memberDepartment: Departments | null | undefined, + memberDepartment: string | null | undefined, memberRoles: string[] | null | undefined, ): boolean { // Privileged roles see everything @@ -77,7 +79,7 @@ export function canViewPolicy( // Policy is department-specific if (policy.visibility === PolicyVisibility.DEPARTMENT) { // No department = can't see department-specific policies - if (!memberDepartment || memberDepartment === Departments.none) { + if (!memberDepartment || memberDepartment === NONE_DEPARTMENT) { return false; } diff --git a/apps/app/src/actions/schema.ts b/apps/app/src/actions/schema.ts index 9a91c755f8..5cc2ab706d 100644 --- a/apps/app/src/actions/schema.ts +++ b/apps/app/src/actions/schema.ts @@ -96,7 +96,11 @@ export const createRiskSchema = z.object({ .min(1, { message: 'Risk description should be at least 1 character' }) .max(255, { message: 'Risk description should be at most 255 characters' }), category: z.nativeEnum(RiskCategory, { error: 'Risk category is required' }), - department: z.nativeEnum(Departments, { error: 'Risk department is required' }), + department: z + .string({ error: 'Risk department is required' }) + .trim() + .min(1, { message: 'Risk department is required' }) + .max(64, { message: 'Risk department must be at most 64 characters' }), assigneeId: z.string().optional().nullable(), }); @@ -111,7 +115,11 @@ export const updateRiskSchema = z.object({ message: 'Risk description is required', }), category: z.nativeEnum(RiskCategory, { error: 'Risk category is required' }), - department: z.nativeEnum(Departments, { error: 'Risk department is required' }), + department: z + .string({ error: 'Risk department is required' }) + .trim() + .min(1, { message: 'Risk department is required' }) + .max(64, { message: 'Risk department must be at most 64 characters' }), assigneeId: z.string().optional().nullable(), status: z.nativeEnum(RiskStatus, { error: 'Risk status is required' }), }); @@ -294,7 +302,11 @@ export const updatePolicyFormSchema = z.object({ id: z.string(), status: z.nativeEnum(PolicyStatus), assigneeId: z.string().optional().nullable(), - department: z.nativeEnum(Departments), + department: z + .string({ error: 'Department is required' }) + .trim() + .min(1, { message: 'Department is required' }) + .max(64, { message: 'Department must be at most 64 characters' }), review_frequency: z.nativeEnum(Frequency), review_date: z.date(), approverId: z.string().optional().nullable(), // Added for selecting an approver diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDetails.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDetails.tsx index bb4831a188..29acab4bc5 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDetails.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDetails.tsx @@ -1,8 +1,9 @@ 'use client'; +import { DepartmentSelect } from '@/components/DepartmentSelect'; import { useApi } from '@/hooks/use-api'; import { Popover, PopoverContent, PopoverTrigger } from '@trycompai/ui/popover'; -import type { Departments, Member, User } from '@db'; +import type { Member, User } from '@db'; import { Button, Calendar, @@ -23,16 +24,6 @@ import { format } from 'date-fns'; import { useMemo, useState } from 'react'; import { toast } from 'sonner'; -const DEPARTMENTS: { value: string; label: string }[] = [ - { value: 'admin', label: 'Admin' }, - { value: 'gov', label: 'Governance' }, - { value: 'hr', label: 'HR' }, - { value: 'it', label: 'IT' }, - { value: 'itsm', label: 'IT Service Management' }, - { value: 'qms', label: 'Quality Management' }, - { value: 'none', label: 'None' }, -]; - const STATUS_OPTIONS = [ { value: 'active', label: 'Active' }, { value: 'inactive', label: 'Inactive' }, @@ -187,24 +178,11 @@ export const EmployeeDetails = ({ {/* Department Field */} - + /> {/* Status Field */} diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/UpdatePolicyOverview.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/UpdatePolicyOverview.tsx index a9e16421d4..a482a26181 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/UpdatePolicyOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/UpdatePolicyOverview.tsx @@ -1,5 +1,6 @@ 'use client'; +import { DepartmentSelect } from '@/components/DepartmentSelect'; import { SelectAssignee } from '@/components/SelectAssignee'; import { Departments, @@ -69,12 +70,12 @@ export function UpdatePolicyOverview({ const [isStatusChangeDialogOpen, setIsStatusChangeDialogOpen] = useState(false); const [pendingChanges, setPendingChanges] = useState<{ assigneeId: { from: string | null; to: string | null } | null; - department: { from: Departments | null; to: Departments } | null; + department: { from: string | null; to: string } | null; reviewFrequency: { from: Frequency | null; to: Frequency } | null; formData: { status: PolicyStatus; assigneeId: string | null; - department: Departments; + department: string; reviewFrequency: Frequency; }; } | null>(null); @@ -82,9 +83,9 @@ export function UpdatePolicyOverview({ // Display the current policy status from the database // This always reflects the actual status stored in the Policy table const displayStatus = policy.status ?? PolicyStatus.draft; - + const [selectedAssigneeId, setSelectedAssigneeId] = useState(policy.assigneeId); - const [selectedDepartment, setSelectedDepartment] = useState( + const [selectedDepartment, setSelectedDepartment] = useState( policy.department || Departments.admin, ); const [selectedFrequency, setSelectedFrequency] = useState( @@ -190,26 +191,11 @@ export function UpdatePolicyOverview({ - + /> {/* Assignee Field */} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx index 5e31384a19..bfa2413603 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx @@ -179,7 +179,8 @@ export function SingleTask({ }; const handleUpdateTask = async ( - updates: Partial> & { + updates: Partial> & { + department?: string | null; notRelevantJustification?: string; }, ) => { diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskPropertiesSidebar.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskPropertiesSidebar.tsx index 7e990e5c2a..3716ef550c 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskPropertiesSidebar.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskPropertiesSidebar.tsx @@ -1,9 +1,10 @@ 'use client'; +import { DepartmentSelect } from '@/components/DepartmentSelect'; import { SelectAssignee } from '@/components/SelectAssignee'; import { useOrganizationMembers } from '@/hooks/use-organization-members'; import { usePermissions } from '@/hooks/use-permissions'; -import type { Departments, Member, Task, TaskFrequency, TaskStatus, User } from '@db'; +import type { Member, Task, TaskFrequency, TaskStatus, User } from '@db'; import { format } from 'date-fns'; import { useParams } from 'next/navigation'; import { @@ -25,11 +26,12 @@ import { Calendar } from 'lucide-react'; import { useState } from 'react'; import { NotRelevantJustificationDialog } from '../../components/NotRelevantJustificationDialog'; import { useTask } from '../hooks/use-task'; -import { taskStatuses, taskFrequencies, taskDepartments } from './constants'; +import { taskStatuses, taskFrequencies } from './constants'; interface TaskPropertiesSidebarProps { handleUpdateTask: ( - data: Partial> & { + data: Partial> & { + department?: string | null; notRelevantJustification?: string; }, ) => void; @@ -137,23 +139,13 @@ export function TaskPropertiesSidebar({ {/* Department */} - + /> {/* Review Date */} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/components/CreateTaskSheet.tsx b/apps/app/src/app/(app)/[orgId]/tasks/components/CreateTaskSheet.tsx index 1ddc01b3c7..2da28af27b 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/components/CreateTaskSheet.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/components/CreateTaskSheet.tsx @@ -1,11 +1,12 @@ 'use client'; +import { DepartmentSelect } from '@/components/DepartmentSelect'; import { SelectAssignee } from '@/components/SelectAssignee'; import { useTaskTemplates } from '@/hooks/use-task-template-api'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@trycompai/ui/form'; import { useMediaQuery } from '@trycompai/ui/hooks'; import MultipleSelector, { Option } from '@trycompai/ui/multiple-selector'; -import { Departments, Member, TaskFrequency, User } from '@db'; +import { Member, TaskFrequency, User } from '@db'; import { zodResolver } from '@hookform/resolvers/zod'; import { Button, @@ -30,7 +31,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; import { z } from 'zod'; -import { taskDepartments, taskFrequencies } from '../[taskId]/components/constants'; +import { taskFrequencies } from '../[taskId]/components/constants'; const createTaskSchema = z.object({ title: z.string().min(1, { @@ -41,7 +42,12 @@ const createTaskSchema = z.object({ }), assigneeId: z.string().nullable().optional(), frequency: z.nativeEnum(TaskFrequency).nullable().optional(), - department: z.nativeEnum(Departments).nullable().optional(), + department: z + .string() + .trim() + .max(64, { message: 'Department must be at most 64 characters' }) + .nullable() + .optional(), controlIds: z.array(z.string()).optional(), taskTemplateId: z.string().nullable().optional(), }); @@ -133,7 +139,7 @@ export function CreateTaskSheet({ members, controls, open, onOpenChange, createT form.setValue('title', selectedTaskTemplate.name); form.setValue('description', selectedTaskTemplate.description); form.setValue('frequency', selectedTaskTemplate.frequency as TaskFrequency); - form.setValue('department', selectedTaskTemplate.department as Departments); + form.setValue('department', selectedTaskTemplate.department ?? null); } }, [selectedTaskTemplate, form]); @@ -158,13 +164,6 @@ export function CreateTaskSheet({ members, controls, open, onOpenChange, createT [], ); - const handleDepartmentChange = useCallback( - (value: string | null, onChange: (value: any) => void) => { - onChange(!value || value === 'none' ? null : value); - }, - [], - ); - const handleControlsChange = useCallback((options: Option[], onChange: (value: any) => void) => { onChange(options.map((option) => option.value)); }, []); @@ -306,33 +305,18 @@ export function CreateTaskSheet({ members, controls, open, onOpenChange, createT { - const displayValue = field.value ? field.value.toUpperCase() : 'Select department'; - return ( - - Department (Optional) - - - - ); - }} + onChange={(value) => field.onChange(value === 'none' ? null : value)} + /> + + + + )} /> void; + disabled?: boolean; + placeholder?: string; + /** + * Custom departments to seed in the dropdown (e.g. values already saved on the org). + * Built-in `Departments` enum values are always included. + */ + customDepartments?: string[]; + className?: string; +} + +const labelFor = (v: string) => v.toUpperCase(); + +/** + * Single-select department picker. Users can pick a built-in `Departments` + * value or click "Add custom..." to type a new department name. Custom values + * added during the session remain available for re-selection in the dropdown. + */ +export function DepartmentSelect({ + value, + onChange, + disabled, + placeholder = 'Select department', + customDepartments, + className, +}: DepartmentSelectProps) { + const [seen, setSeen] = useState>( + () => + new Set( + [value, ...(customDepartments ?? [])].filter( + (v): v is string => Boolean(v) && v !== ADD_CUSTOM_VALUE, + ), + ), + ); + + useEffect(() => { + setSeen((prev) => { + let next: Set | null = null; + for (const v of [value, ...(customDepartments ?? [])]) { + if (v && v !== ADD_CUSTOM_VALUE && !prev.has(v)) { + if (!next) next = new Set(prev); + next.add(v); + } + } + return next ?? prev; + }); + }, [value, customDepartments]); + + const options = useMemo(() => { + const map = new Map(); + for (const dept of Object.values(Departments)) { + map.set(dept, labelFor(dept)); + } + for (const dept of seen) { + if (!map.has(dept)) map.set(dept, labelFor(dept)); + } + return Array.from(map, ([v, label]) => ({ value: v, label })); + }, [seen]); + + const [isAddingCustom, setIsAddingCustom] = useState(false); + const [draft, setDraft] = useState(''); + const inputRef = useRef(null); + + useEffect(() => { + if (isAddingCustom) inputRef.current?.focus(); + }, [isAddingCustom]); + + const handleSelectChange = (next: string | null) => { + if (next === ADD_CUSTOM_VALUE) { + setDraft(''); + setIsAddingCustom(true); + return; + } + if (next && next !== value) onChange(next); + }; + + const handleSaveCustom = () => { + const trimmed = draft.trim(); + if ( + !trimmed || + trimmed === ADD_CUSTOM_VALUE || + trimmed.length > DEPARTMENT_MAX_LENGTH + ) + return; + setSeen((prev) => { + if (prev.has(trimmed)) return prev; + const next = new Set(prev); + next.add(trimmed); + return next; + }); + if (trimmed !== value) onChange(trimmed); + setIsAddingCustom(false); + setDraft(''); + }; + + const handleCancelCustom = () => { + setIsAddingCustom(false); + setDraft(''); + }; + + if (isAddingCustom) { + return ( +
+ + setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSaveCustom(); + } else if (e.key === 'Escape') { + e.preventDefault(); + handleCancelCustom(); + } + }} + placeholder="Department name" + maxLength={DEPARTMENT_MAX_LENGTH} + disabled={disabled} + /> + + + +
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/apps/app/src/components/forms/risks/create-risk-form.tsx b/apps/app/src/components/forms/risks/create-risk-form.tsx index 58ec5b9085..32eabfc7ab 100644 --- a/apps/app/src/components/forms/risks/create-risk-form.tsx +++ b/apps/app/src/components/forms/risks/create-risk-form.tsx @@ -1,6 +1,7 @@ 'use client'; import { createRiskSchema } from '@/actions/schema'; +import { DepartmentSelect } from '@/components/DepartmentSelect'; import { SelectAssignee } from '@/components/SelectAssignee'; import { useRiskActions } from '@/hooks/use-risks'; import { Button } from '@trycompai/ui/button'; @@ -130,18 +131,11 @@ export function CreateRisk({ assignees, onSuccess }: CreateRiskProps) { render={({ field }) => ( Department - + )} diff --git a/apps/app/src/components/forms/risks/risk-overview.tsx b/apps/app/src/components/forms/risks/risk-overview.tsx index 2656a636a0..b7a774be62 100644 --- a/apps/app/src/components/forms/risks/risk-overview.tsx +++ b/apps/app/src/components/forms/risks/risk-overview.tsx @@ -1,6 +1,7 @@ 'use client'; import { updateRiskSchema } from '@/actions/schema'; +import { DepartmentSelect } from '@/components/DepartmentSelect'; import { SelectAssignee } from '@/components/SelectAssignee'; import { StatusIndicator } from '@/components/status-indicator'; import { usePermissions } from '@/hooks/use-permissions'; @@ -134,22 +135,11 @@ export function UpdateRiskOverview({ - + /> diff --git a/apps/app/src/hooks/use-risks.ts b/apps/app/src/hooks/use-risks.ts index a893d5165f..dce9659941 100644 --- a/apps/app/src/hooks/use-risks.ts +++ b/apps/app/src/hooks/use-risks.ts @@ -6,7 +6,6 @@ import { ApiResponse } from '@/lib/api-client'; import { useCallback, useMemo } from 'react'; import type { RiskCategory, - Departments, RiskStatus, Likelihood, Impact, @@ -39,7 +38,7 @@ export interface Risk { title: string; description: string; category: RiskCategory; - department: Departments | null; + department: string | null; status: RiskStatus; likelihood: Likelihood; impact: Impact; @@ -83,7 +82,7 @@ interface CreateRiskData { title: string; description?: string; category?: RiskCategory; - department?: Departments; + department?: string; status?: RiskStatus; likelihood?: Likelihood; impact?: Impact; @@ -98,7 +97,7 @@ interface UpdateRiskData { title?: string; description?: string; category?: RiskCategory; - department?: Departments | null; + department?: string | null; status?: RiskStatus; likelihood?: Likelihood; impact?: Impact; diff --git a/apps/app/src/lib/embedding/index.ts b/apps/app/src/lib/embedding/index.ts index 57c983815f..bcfe629c60 100644 --- a/apps/app/src/lib/embedding/index.ts +++ b/apps/app/src/lib/embedding/index.ts @@ -1,6 +1,5 @@ import 'server-only'; -import { Departments } from '@db'; import { Index } from '@upstash/vector'; import { openai } from '@ai-sdk/openai'; import { embedMany } from 'ai'; @@ -11,7 +10,7 @@ export type EntityKind = 'risk' | 'vendor' | 'task'; interface EntityInput { id: string; text: string; - department?: Departments; + department?: string; } interface UpsertOptions { @@ -47,7 +46,7 @@ interface FindSimilarTasksOptions { export interface SimilarTaskResult { id: string; // raw task id (sourceId), not the prefixed embedding id score: number; - department?: Departments; + department?: string; } // `text-embedding-3-large` truncated to 1536 dims via Matryoshka. The @@ -92,7 +91,7 @@ export function computeEntityContentHash({ department, }: { text: string; - department?: Departments; + department?: string; }): string { return createHash('sha256') .update(`${EMBEDDING_MODEL}:${EMBEDDING_DIMENSIONS}:${department ?? ''}:${text}`) @@ -190,7 +189,7 @@ export async function findSimilarTasks({ return { id: meta.sourceId ?? String(r.id), score: r.score, - department: meta.department ? (meta.department as Departments) : undefined, + department: meta.department ?? undefined, }; }); } diff --git a/apps/app/src/lib/link-suggestions.ts b/apps/app/src/lib/link-suggestions.ts index 693affa834..a1ef12895e 100644 --- a/apps/app/src/lib/link-suggestions.ts +++ b/apps/app/src/lib/link-suggestions.ts @@ -1,5 +1,3 @@ -import { Departments } from '@db'; - const DEFAULT_THRESHOLD = 0.65; const DEFAULT_TOP_K = 5; const DEFAULT_DEPARTMENT_BOOST = 0.05; @@ -7,11 +5,11 @@ const DEFAULT_DEPARTMENT_BOOST = 0.05; export interface Candidate { id: string; score: number; // raw cosine similarity from the vector store - department?: Departments; + department?: string; } export interface LinkSuggestionsOptions { - source: { department?: Departments }; + source: { department?: string }; candidates: Candidate[]; threshold?: number; topK?: number; @@ -39,10 +37,10 @@ export function linkSuggestions({ if (candidates.length === 0) return []; const sourceDept = source.department; - const shouldBoost = (candidateDept?: Departments) => + const shouldBoost = (candidateDept?: string) => sourceDept !== undefined && candidateDept !== undefined && - candidateDept !== Departments.none && + candidateDept !== 'none' && sourceDept === candidateDept; const boosted: LinkSuggestion[] = candidates.map((c) => ({ diff --git a/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest-helpers.ts b/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest-helpers.ts index bc738efc26..da26088017 100644 --- a/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest-helpers.ts +++ b/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest-helpers.ts @@ -2,7 +2,7 @@ * Helper types and pure filter function for the policy acknowledgment digest. * Extracted from the scheduled task for testability. */ -import type { Departments, PolicyVisibility } from '@db'; +import type { PolicyVisibility } from '@db'; // Inlined from @trycompai/auth to avoid pulling that package into the Trigger.dev bundle. // Keep in sync with packages/auth/src/permissions.ts BUILT_IN_ROLE_OBLIGATIONS. @@ -93,13 +93,13 @@ export interface DigestPolicy { name: string; signedBy: string[]; visibility: PolicyVisibility; - visibleToDepartments: Departments[]; + visibleToDepartments: string[]; } export interface DigestMember { id: string; role: string; - department: Departments | null; + department: string | null; user: { id: string; name: string | null; email: string; role?: string | null }; } diff --git a/packages/db/prisma/migrations/20260602120000_policy_department_to_string/migration.sql b/packages/db/prisma/migrations/20260602120000_policy_department_to_string/migration.sql new file mode 100644 index 0000000000..a84f3c456a --- /dev/null +++ b/packages/db/prisma/migrations/20260602120000_policy_department_to_string/migration.sql @@ -0,0 +1,7 @@ +-- AlterTable +ALTER TABLE "Policy" ALTER COLUMN "department" TYPE TEXT USING "department"::TEXT; + +-- AlterTable +ALTER TABLE "Policy" ALTER COLUMN "visibleToDepartments" DROP DEFAULT; +ALTER TABLE "Policy" ALTER COLUMN "visibleToDepartments" TYPE TEXT[] USING "visibleToDepartments"::TEXT[]; +ALTER TABLE "Policy" ALTER COLUMN "visibleToDepartments" SET DEFAULT ARRAY[]::TEXT[]; diff --git a/packages/db/prisma/migrations/20260602130000_member_risk_task_department_to_string/migration.sql b/packages/db/prisma/migrations/20260602130000_member_risk_task_department_to_string/migration.sql new file mode 100644 index 0000000000..3b9e6b145f --- /dev/null +++ b/packages/db/prisma/migrations/20260602130000_member_risk_task_department_to_string/migration.sql @@ -0,0 +1,12 @@ +-- AlterTable: Member.department — convert enum column to TEXT, preserve "none" default +ALTER TABLE "Member" ALTER COLUMN "department" DROP DEFAULT; +ALTER TABLE "Member" ALTER COLUMN "department" TYPE TEXT USING "department"::TEXT; +ALTER TABLE "Member" ALTER COLUMN "department" SET DEFAULT 'none'; + +-- AlterTable: Risk.department — convert nullable enum column to TEXT (no default) +ALTER TABLE "Risk" ALTER COLUMN "department" TYPE TEXT USING "department"::TEXT; + +-- AlterTable: Task.department — convert nullable enum column to TEXT, preserve "none" default +ALTER TABLE "Task" ALTER COLUMN "department" DROP DEFAULT; +ALTER TABLE "Task" ALTER COLUMN "department" TYPE TEXT USING "department"::TEXT; +ALTER TABLE "Task" ALTER COLUMN "department" SET DEFAULT 'none'; diff --git a/packages/db/prisma/schema/auth.prisma b/packages/db/prisma/schema/auth.prisma index 6c9c12383c..8eb3abab39 100644 --- a/packages/db/prisma/schema/auth.prisma +++ b/packages/db/prisma/schema/auth.prisma @@ -200,7 +200,7 @@ model Member { role String // Purposefully a string, since BetterAuth doesn't support enums this way createdAt DateTime @default(now()) - department Departments @default(none) + department String @default("none") jobTitle String? isActive Boolean @default(true) deactivated Boolean @default(false) diff --git a/packages/db/prisma/schema/policy.prisma b/packages/db/prisma/schema/policy.prisma index 50f1c87853..2cf80a4373 100644 --- a/packages/db/prisma/schema/policy.prisma +++ b/packages/db/prisma/schema/policy.prisma @@ -16,7 +16,7 @@ model Policy { content Json[] draftContent Json[] @default([]) frequency Frequency? - department Departments? + department String? isRequiredToSign Boolean @default(true) signedBy String[] @default([]) reviewDate DateTime? @@ -26,7 +26,7 @@ model Policy { // Visibility settings (for department-specific policies) visibility PolicyVisibility @default(ALL) - visibleToDepartments Departments[] @default([]) + visibleToDepartments String[] @default([]) // Dates createdAt DateTime @default(now()) diff --git a/packages/db/prisma/schema/risk.prisma b/packages/db/prisma/schema/risk.prisma index b4a95441dd..76ae882a47 100644 --- a/packages/db/prisma/schema/risk.prisma +++ b/packages/db/prisma/schema/risk.prisma @@ -4,7 +4,7 @@ model Risk { title String description String category RiskCategory - department Departments? + department String? status RiskStatus @default(open) likelihood Likelihood @default(very_unlikely) impact Impact @default(insignificant) diff --git a/packages/db/prisma/schema/task.prisma b/packages/db/prisma/schema/task.prisma index 3b054a0597..37b0c9925c 100644 --- a/packages/db/prisma/schema/task.prisma +++ b/packages/db/prisma/schema/task.prisma @@ -8,7 +8,7 @@ model Task { frequency TaskFrequency? integrationScheduleFrequency TaskFrequency @default(daily) integrationLastRunAt DateTime? - department Departments? @default(none) + department String? @default("none") order Int @default(0) // Dates