Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion apps/backend/src/app/tasks/dto/create-task.dto.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsPositive, Max, ValidateIf } from 'class-validator';
import {
IsBoolean,
IsInt,
IsPositive,
Max,
Validate,
ValidateIf,
} from 'class-validator';
import type { TaskStatus, TaskPriority, EditorData } from '@zuko/sales';
import { SinglePersonReference } from './single-person-reference.validator';

export class CreateTaskDto {
@ApiProperty({ example: 'Follow up with client' })
Expand Down Expand Up @@ -60,4 +68,29 @@ export class CreateTaskDto {
// Postgres INTEGER bound; larger values would fail in Prisma with a 500.
@Max(2_147_483_647)
assigneeUserId?: number | null;

@ApiPropertyOptional({
type: Number,
nullable: true,
description:
'Sales contact this task is about. Mutually exclusive with prospectId.',
})
@ValidateIf((_object, value) => value !== undefined && value !== null)
@IsInt()
@IsPositive()
@Max(2_147_483_647)
@Validate(SinglePersonReference)
contactId?: number | null;

@ApiPropertyOptional({
type: Number,
nullable: true,
description:
'Sales prospect this task is about. Mutually exclusive with contactId.',
})
@ValidateIf((_object, value) => value !== undefined && value !== null)
@IsInt()
@IsPositive()
@Max(2_147_483_647)
prospectId?: number | null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {
ValidatorConstraint,
type ValidationArguments,
type ValidatorConstraintInterface,
} from 'class-validator';

/** A task references at most one person: a contact or a prospect. */
@ValidatorConstraint({ name: 'singlePersonReference' })
export class SinglePersonReference implements ValidatorConstraintInterface {
validate(_value: unknown, args: ValidationArguments) {
const { prospectId } = args.object as { prospectId?: unknown };
return prospectId === undefined || prospectId === null;
}

defaultMessage() {
return 'contactId and prospectId cannot both be set';
}
}
35 changes: 34 additions & 1 deletion apps/backend/src/app/tasks/dto/update-task.dto.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsPositive, Max, ValidateIf } from 'class-validator';
import {
IsBoolean,
IsInt,
IsPositive,
Max,
Validate,
ValidateIf,
} from 'class-validator';
import type { TaskStatus, TaskPriority, EditorData } from '@zuko/sales';
import { SinglePersonReference } from './single-person-reference.validator';

export class UpdateTaskDto {
@ApiPropertyOptional({ example: 'Follow up with client' })
Expand Down Expand Up @@ -61,4 +69,29 @@ export class UpdateTaskDto {
// Postgres INTEGER bound; larger values would fail in Prisma with a 500.
@Max(2_147_483_647)
assigneeUserId?: number | null;

@ApiPropertyOptional({
type: Number,
nullable: true,
description:
'Sales contact this task is about. Setting it clears prospectId; null clears it; omit to keep it.',
})
@ValidateIf((_object, value) => value !== undefined && value !== null)
@IsInt()
@IsPositive()
@Max(2_147_483_647)
@Validate(SinglePersonReference)
contactId?: number | null;

@ApiPropertyOptional({
type: Number,
nullable: true,
description:
'Sales prospect this task is about. Setting it clears contactId; null clears it; omit to keep it.',
})
@ValidateIf((_object, value) => value !== undefined && value !== null)
@IsInt()
@IsPositive()
@Max(2_147_483_647)
prospectId?: number | null;
}
69 changes: 69 additions & 0 deletions apps/backend/src/app/tasks/tasks.validation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,75 @@ describe('Task isSales HTTP validation', () => {
expect(dto.assignee).toBeUndefined();
});

describe.each(['contactId', 'prospectId'] as const)(
'person reference %s',
(field) => {
const dtoOf = () => mock.mock.calls[0][method === 'POST' ? 1 : 2];

it.each(
[
'5',
0,
-3,
1.5,
true,
false,
{},
[],
{ connect: { id: 5 } },
2_147_483_648,
1e20,
].map((value) => ({ value })),
)('rejects $value before calling service', async ({ value }) => {
const response = await invoke({ title: 'x', [field]: value });
expect(response.status).toBe(400);
expect(JSON.stringify((await response.json()).message)).toContain(
field,
);
expect(mock).not.toHaveBeenCalled();
});

it.each([12, 2_147_483_647, null])(
'passes %j through unchanged',
async (value) => {
const response = await invoke({ title: 'x', [field]: value });
expect(response.status).toBe(method === 'POST' ? 201 : 200);
expect(dtoOf()[field]).toBe(value);
},
);
},
);

it('leaves omitted person references undefined so they are preserved', async () => {
await invoke({ title: 'x' });
const dto = mock.mock.calls[0][method === 'POST' ? 1 : 2];
expect(dto.contactId).toBeUndefined();
expect(dto.prospectId).toBeUndefined();
});

it('rejects both contactId and prospectId in one request', async () => {
const response = await invoke({
title: 'x',
contactId: 3,
prospectId: 4,
});
expect(response.status).toBe(400);
expect(JSON.stringify((await response.json()).message)).toContain(
'contactId and prospectId',
);
expect(mock).not.toHaveBeenCalled();
});

it.each([
{ contactId: 3, prospectId: null },
{ contactId: null, prospectId: 4 },
{ contactId: null, prospectId: null },
])('accepts one reference with the other null: %j', async (refs) => {
const response = await invoke({ title: 'x', ...refs });
expect(response.status).toBe(method === 'POST' ? 201 : 200);
expect(mock).toHaveBeenCalledOnce();
});

it('returns 400 when the service rejects a foreign-org assignee', async () => {
mock.mockRejectedValueOnce(
new BadRequestException(
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/lib/api/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ export interface TaskOwner {
};
}

export interface TaskPersonSummary {
id: number;
name: string;
}

export interface Task {
id: number;
organizationId: number;
Expand All @@ -32,6 +37,11 @@ export interface Task {
assignee?: string | null;
assigneeUserId?: number | null;
assigneeUser?: TaskOwner['user'] | null;
/** Optional person reference; at most one of contactId/prospectId is set. */
contactId?: number | null;
prospectId?: number | null;
contact?: TaskPersonSummary | null;
prospect?: TaskPersonSummary | null;
owners: TaskOwner[];
subtasks: Task[];
createdAt: string;
Expand All @@ -57,6 +67,9 @@ export interface CreateTaskDto {
completedAt?: string;
parentId?: number;
assigneeUserId?: number | null;
/** Send at most one of contactId/prospectId. */
contactId?: number | null;
prospectId?: number | null;
}

export interface UpdateTaskDto {
Expand All @@ -68,6 +81,9 @@ export interface UpdateTaskDto {
completedAt?: string | null;
parentId?: number | null;
assigneeUserId?: number | null;
/** Setting one clears the other; null clears it; omit to keep it. */
contactId?: number | null;
prospectId?: number | null;
}

export interface TaskFilters {
Expand Down
8 changes: 8 additions & 0 deletions libs/models/prisma/core.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -349,11 +349,17 @@ model Task {
assignee String?
// Stable assignee identity. Distinct from owners (creator/ownership links).
assigneeUserId Int? @map("assignee_user_id")
// Optional person the task is about: a contact or a prospect, never both
// (enforced by the task_single_person_reference check constraint).
contactId Int? @map("contact_id")
prospectId Int? @map("prospect_id")

parent Task? @relation("TaskSubtasks", fields: [parentId], references: [id])
subtasks Task[] @relation("TaskSubtasks")
owners TaskOwner[]
assigneeUser User? @relation("TaskAssignee", fields: [assigneeUserId], references: [id], onDelete: SetNull)
contact Contact? @relation("ContactTasks", fields: [contactId], references: [id], onDelete: SetNull)
prospect Prospect? @relation("ProspectTasks", fields: [prospectId], references: [id], onDelete: SetNull)

createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
Expand All @@ -362,6 +368,8 @@ model Task {
@@index([parentId])
@@index([status])
@@index([assigneeUserId])
@@index([contactId])
@@index([prospectId])
@@map("task")
@@schema("public")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
-- AlterTable
ALTER TABLE "task" ADD COLUMN "contact_id" INTEGER,
ADD COLUMN "prospect_id" INTEGER;

-- A task references at most one person: a contact or a prospect, never both.
ALTER TABLE "task" ADD CONSTRAINT "task_single_person_reference_check" CHECK ("contact_id" IS NULL OR "prospect_id" IS NULL);

-- CreateIndex
CREATE INDEX "task_contact_id_idx" ON "task"("contact_id");

-- CreateIndex
CREATE INDEX "task_prospect_id_idx" ON "task"("prospect_id");

-- AddForeignKey
ALTER TABLE "task" ADD CONSTRAINT "task_contact_id_fkey" FOREIGN KEY ("contact_id") REFERENCES "sales"."contact"("id") ON DELETE SET NULL ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "task" ADD CONSTRAINT "task_prospect_id_fkey" FOREIGN KEY ("prospect_id") REFERENCES "sales"."prospect"("id") ON DELETE SET NULL ON UPDATE CASCADE;
2 changes: 2 additions & 0 deletions libs/models/prisma/sales.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ model Contact {
deals DealContact[]
leads Lead[] @relation("ContactLeads")
prospects Prospect[] @relation("ContactProspects")
tasks Task[] @relation("ContactTasks")

@@index([organizationId])
@@index([isHidden])
Expand Down Expand Up @@ -405,6 +406,7 @@ model Prospect {
lead Lead? @relation("ProspectLead", fields: [leadId], references: [id], onDelete: SetNull)
memberships CampaignMembership[] @relation("ProspectMemberships")
events CampaignEvent[] @relation("ProspectEvents")
tasks Task[] @relation("ProspectTasks")

// Identity resolution keys — one human, one prospect, per organization.
@@unique([organizationId, email])
Expand Down
Loading