From 84510e0cad188ef2a898d02fcb4b2024b61963c4 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 2 Jun 2026 14:22:29 -0400 Subject: [PATCH 01/12] fix(app): update departments select in policy details page --- .../components/UpdatePolicyOverview.tsx | 30 +-- apps/app/src/components/DepartmentSelect.tsx | 180 ++++++++++++++++++ 2 files changed, 188 insertions(+), 22 deletions(-) create mode 100644 apps/app/src/components/DepartmentSelect.tsx 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/components/DepartmentSelect.tsx b/apps/app/src/components/DepartmentSelect.tsx new file mode 100644 index 0000000000..3dd5861685 --- /dev/null +++ b/apps/app/src/components/DepartmentSelect.tsx @@ -0,0 +1,180 @@ +'use client'; + +import { Departments } from '@db'; +import { + Button, + HStack, + Input, + Select, + SelectContent, + SelectItem, + SelectSeparator, + SelectTrigger, + SelectValue, +} from '@trycompai/design-system'; +import { Add, Checkmark, Close } from '@trycompai/design-system/icons'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +const ADD_CUSTOM_VALUE = '__add_custom__'; + +interface DepartmentSelectProps { + value: string; + onChange: (value: string) => 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(Boolean)), + ); + + useEffect(() => { + setSeen((prev) => { + let next: Set | null = null; + for (const v of [value, ...(customDepartments ?? [])]) { + if (v && !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) 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" + disabled={disabled} + /> + + + +
+ ); + } + + return ( +
+ +
+ ); +} From cb456c608233a78e728e4f9c09da24fc23411534 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 2 Jun 2026 14:41:33 -0400 Subject: [PATCH 02/12] fix(db): change departments type of policy model --- .../migration.sql | 7 +++++++ packages/db/prisma/schema/policy.prisma | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 packages/db/prisma/migrations/20260602120000_policy_department_to_string/migration.sql 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/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()) From 2d9f2ea80d17554a70d970903041498725c6de78 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 2 Jun 2026 14:42:43 -0400 Subject: [PATCH 03/12] fix(api): update policy endpoints to support custom departments --- .../admin-policies.controller.ts | 17 +++++++++----- .../dto/create-admin-policy.dto.ts | 23 +++++++++++++++---- .../api/src/policies/dto/create-policy.dto.ts | 21 +++++++++++++---- .../src/policies/dto/policy-responses.dto.ts | 11 +++++---- apps/api/src/utils/department-visibility.ts | 2 +- .../policy-acknowledgment-digest-helpers.ts | 2 +- 6 files changed, 55 insertions(+), 21 deletions(-) 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/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/utils/department-visibility.ts b/apps/api/src/utils/department-visibility.ts index 973fd2396d..fba0af6c8e 100644 --- a/apps/api/src/utils/department-visibility.ts +++ b/apps/api/src/utils/department-visibility.ts @@ -59,7 +59,7 @@ export function buildPolicyVisibilityFilter( export function canViewPolicy( policy: { visibility: PolicyVisibility; - visibleToDepartments: Departments[]; + visibleToDepartments: string[]; }, memberDepartment: Departments | null | undefined, memberRoles: string[] | null | undefined, 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..be11ed0127 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 @@ -93,7 +93,7 @@ export interface DigestPolicy { name: string; signedBy: string[]; visibility: PolicyVisibility; - visibleToDepartments: Departments[]; + visibleToDepartments: string[]; } export interface DigestMember { From 1409aae9a45a9f2215b8c2c0702432a27ef53f02 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 2 Jun 2026 22:21:39 -0400 Subject: [PATCH 04/12] fix(db): change departments field type to string for member, risk and task models --- .../migration.sql | 12 ++++++++++++ packages/db/prisma/schema/auth.prisma | 2 +- packages/db/prisma/schema/risk.prisma | 2 +- packages/db/prisma/schema/task.prisma | 2 +- 4 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 packages/db/prisma/migrations/20260602130000_member_risk_task_department_to_string/migration.sql 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/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 From cc9611ac1d374dd2b562fb9c4c8f78d8ebf64a4e Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 2 Jun 2026 22:23:40 -0400 Subject: [PATCH 05/12] fix(api): update member, risk and task endpoints to support custom departments --- apps/api/src/auth/types.ts | 6 +-- apps/api/src/people/dto/create-people.dto.ts | 17 +++++-- .../src/people/dto/people-responses.dto.ts | 7 +-- apps/api/src/risks/dto/create-risk.dto.ts | 23 ++++++++-- apps/api/src/risks/dto/get-risks-query.dto.ts | 23 ++++++++-- apps/api/src/risks/dto/risk-response.dto.ts | 7 +-- apps/api/src/tasks/dto/swagger.dto.ts | 23 ++++++---- apps/api/src/tasks/tasks.controller.ts | 46 +++++++++++++++++-- apps/api/src/tasks/tasks.service.ts | 8 ++-- apps/api/src/utils/department-visibility.ts | 12 +++-- 10 files changed, 125 insertions(+), 47 deletions(-) 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/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..02e7582490 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,16 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { + IsEnum, + IsInt, + IsOptional, + IsString, + Max, + MaxLength, + Min, +} from 'class-validator'; import { 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 +94,16 @@ 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() + @MaxLength(DEPARTMENT_MAX_LENGTH) + 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..97aeafa4db 100644 --- a/apps/api/src/tasks/dto/swagger.dto.ts +++ b/apps/api/src/tasks/dto/swagger.dto.ts @@ -38,10 +38,12 @@ 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', default: 'none', required: false, + maxLength: 64, }) department?: string; @@ -97,9 +99,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 +147,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 +207,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 fba0af6c8e..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, }; @@ -61,7 +63,7 @@ export function canViewPolicy( visibility: PolicyVisibility; 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; } From c879d4e14e81183517bb902d960dc0494bedaaed Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 2 Jun 2026 22:24:58 -0400 Subject: [PATCH 06/12] fix(app): update UI to support cusotm departments on People, Task, and Risk pages --- apps/app/src/actions/schema.ts | 12 +++- .../components/EmployeeDetails.tsx | 32 ++-------- .../tasks/[taskId]/components/SingleTask.tsx | 3 +- .../components/TaskPropertiesSidebar.tsx | 28 ++++----- .../tasks/components/CreateTaskSheet.tsx | 58 +++++++------------ .../forms/risks/create-risk-form.tsx | 18 ++---- .../components/forms/risks/risk-overview.tsx | 20 ++----- .../policy-acknowledgment-digest-helpers.ts | 4 +- 8 files changed, 61 insertions(+), 114 deletions(-) diff --git a/apps/app/src/actions/schema.ts b/apps/app/src/actions/schema.ts index 9a91c755f8..3aa41b3f34 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' }), }); 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]/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)} + /> + + + + )} /> ( 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/trigger/tasks/task/policy-acknowledgment-digest-helpers.ts b/apps/app/src/trigger/tasks/task/policy-acknowledgment-digest-helpers.ts index be11ed0127..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. @@ -99,7 +99,7 @@ export interface DigestPolicy { export interface DigestMember { id: string; role: string; - department: Departments | null; + department: string | null; user: { id: string; name: string | null; email: string; role?: string | null }; } From 289ffee4a0a909992c5c330ab24a46dc87440abd Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 2 Jun 2026 23:07:06 -0400 Subject: [PATCH 07/12] fix(api): reject whitespace-only department filter in GetRisksQueryDto --- apps/api/src/risks/dto/get-risks-query.dto.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 02e7582490..0256f6d7bf 100644 --- a/apps/api/src/risks/dto/get-risks-query.dto.ts +++ b/apps/api/src/risks/dto/get-risks-query.dto.ts @@ -2,13 +2,14 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { IsEnum, IsInt, + IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min, } from 'class-validator'; -import { Type } from 'class-transformer'; +import { Transform, Type } from 'class-transformer'; import { RiskCategory, Departments, RiskStatus } from '@db'; import { DEPARTMENT_MAX_LENGTH } from '../../policies/dto/create-policy.dto'; @@ -102,7 +103,9 @@ export class GetRisksQueryDto { }) @IsOptional() @IsString() + @IsNotEmpty() @MaxLength(DEPARTMENT_MAX_LENGTH) + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) department?: string; @ApiPropertyOptional({ From 88a842020befe162d52b25892fe843cca56474ff Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 2 Jun 2026 23:16:27 -0400 Subject: [PATCH 08/12] fix(app): prevent custom department sentinel from colliding with real values --- apps/app/src/components/DepartmentSelect.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/app/src/components/DepartmentSelect.tsx b/apps/app/src/components/DepartmentSelect.tsx index 3dd5861685..8e974ef7b7 100644 --- a/apps/app/src/components/DepartmentSelect.tsx +++ b/apps/app/src/components/DepartmentSelect.tsx @@ -15,7 +15,10 @@ import { import { Add, Checkmark, Close } from '@trycompai/design-system/icons'; import { useEffect, useMemo, useRef, useState } from 'react'; -const ADD_CUSTOM_VALUE = '__add_custom__'; +// Padded beyond the backend's DEPARTMENT_MAX_LENGTH (64) so this sentinel can +// never equal a persisted custom department value. +const ADD_CUSTOM_VALUE = + '__compai_add_custom_department_action_sentinel_do_not_use_as_a_value__'; interface DepartmentSelectProps { value: string; @@ -46,14 +49,19 @@ export function DepartmentSelect({ className, }: DepartmentSelectProps) { const [seen, setSeen] = useState>( - () => new Set([value, ...(customDepartments ?? [])].filter(Boolean)), + () => + 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 && !prev.has(v)) { + if (v && v !== ADD_CUSTOM_VALUE && !prev.has(v)) { if (!next) next = new Set(prev); next.add(v); } @@ -92,7 +100,7 @@ export function DepartmentSelect({ const handleSaveCustom = () => { const trimmed = draft.trim(); - if (!trimmed) return; + if (!trimmed || trimmed === ADD_CUSTOM_VALUE) return; setSeen((prev) => { if (prev.has(trimmed)) return prev; const next = new Set(prev); From 25bc02a5eabf8a50fb1d08098d95e26e3276cf1f Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Wed, 3 Jun 2026 00:45:57 -0400 Subject: [PATCH 09/12] fix(api): update validation of department in CreateTaskDto --- apps/api/src/tasks/dto/swagger.dto.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/api/src/tasks/dto/swagger.dto.ts b/apps/api/src/tasks/dto/swagger.dto.ts index 97aeafa4db..b0c7617d8d 100644 --- a/apps/api/src/tasks/dto/swagger.dto.ts +++ b/apps/api/src/tasks/dto/swagger.dto.ts @@ -41,6 +41,7 @@ export class CreateTaskDto { 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, From 75856a2f7bba91c82b0b6e6d22eb151a41f2c21b Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Wed, 3 Jun 2026 01:08:44 -0400 Subject: [PATCH 10/12] fix(app): enforce 64-char limit on custom department input --- apps/app/src/components/DepartmentSelect.tsx | 22 ++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/apps/app/src/components/DepartmentSelect.tsx b/apps/app/src/components/DepartmentSelect.tsx index 8e974ef7b7..4dac469e00 100644 --- a/apps/app/src/components/DepartmentSelect.tsx +++ b/apps/app/src/components/DepartmentSelect.tsx @@ -15,8 +15,12 @@ import { import { Add, Checkmark, Close } from '@trycompai/design-system/icons'; import { useEffect, useMemo, useRef, useState } from 'react'; -// Padded beyond the backend's DEPARTMENT_MAX_LENGTH (64) so this sentinel can -// never equal a persisted custom department value. +// Mirrors the backend's DEPARTMENT_MAX_LENGTH in apps/api so the input rejects +// values the API would reject anyway. +const DEPARTMENT_MAX_LENGTH = 64; + +// Padded beyond DEPARTMENT_MAX_LENGTH so this sentinel can never equal a +// persisted custom department value. const ADD_CUSTOM_VALUE = '__compai_add_custom_department_action_sentinel_do_not_use_as_a_value__'; @@ -100,7 +104,12 @@ export function DepartmentSelect({ const handleSaveCustom = () => { const trimmed = draft.trim(); - if (!trimmed || trimmed === ADD_CUSTOM_VALUE) return; + 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); @@ -135,6 +144,7 @@ export function DepartmentSelect({ } }} placeholder="Department name" + maxLength={DEPARTMENT_MAX_LENGTH} disabled={disabled} />