diff --git a/apps/backend/src/app/tasks/dto/create-task.dto.ts b/apps/backend/src/app/tasks/dto/create-task.dto.ts index b2c7349..e2c5735 100644 --- a/apps/backend/src/app/tasks/dto/create-task.dto.ts +++ b/apps/backend/src/app/tasks/dto/create-task.dto.ts @@ -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' }) @@ -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; } diff --git a/apps/backend/src/app/tasks/dto/single-person-reference.validator.ts b/apps/backend/src/app/tasks/dto/single-person-reference.validator.ts new file mode 100644 index 0000000..90c29c1 --- /dev/null +++ b/apps/backend/src/app/tasks/dto/single-person-reference.validator.ts @@ -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'; + } +} diff --git a/apps/backend/src/app/tasks/dto/update-task.dto.ts b/apps/backend/src/app/tasks/dto/update-task.dto.ts index 44e8444..5f8ea50 100644 --- a/apps/backend/src/app/tasks/dto/update-task.dto.ts +++ b/apps/backend/src/app/tasks/dto/update-task.dto.ts @@ -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' }) @@ -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; } diff --git a/apps/backend/src/app/tasks/tasks.validation.spec.ts b/apps/backend/src/app/tasks/tasks.validation.spec.ts index 58c9e00..7459ab3 100644 --- a/apps/backend/src/app/tasks/tasks.validation.spec.ts +++ b/apps/backend/src/app/tasks/tasks.validation.spec.ts @@ -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( diff --git a/apps/web/src/lib/api/tasks.ts b/apps/web/src/lib/api/tasks.ts index ac10740..356977e 100644 --- a/apps/web/src/lib/api/tasks.ts +++ b/apps/web/src/lib/api/tasks.ts @@ -18,6 +18,11 @@ export interface TaskOwner { }; } +export interface TaskPersonSummary { + id: number; + name: string; +} + export interface Task { id: number; organizationId: number; @@ -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; @@ -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 { @@ -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 { diff --git a/libs/models/prisma/core.prisma b/libs/models/prisma/core.prisma index a2fd4f6..28aebb8 100644 --- a/libs/models/prisma/core.prisma +++ b/libs/models/prisma/core.prisma @@ -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") @@ -362,6 +368,8 @@ model Task { @@index([parentId]) @@index([status]) @@index([assigneeUserId]) + @@index([contactId]) + @@index([prospectId]) @@map("task") @@schema("public") } diff --git a/libs/models/prisma/migrations/20261102000000_add_task_person_reference/migration.sql b/libs/models/prisma/migrations/20261102000000_add_task_person_reference/migration.sql new file mode 100644 index 0000000..55bf926 --- /dev/null +++ b/libs/models/prisma/migrations/20261102000000_add_task_person_reference/migration.sql @@ -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; diff --git a/libs/models/prisma/sales.prisma b/libs/models/prisma/sales.prisma index b19c8c2..8141ff0 100644 --- a/libs/models/prisma/sales.prisma +++ b/libs/models/prisma/sales.prisma @@ -29,6 +29,7 @@ model Contact { deals DealContact[] leads Lead[] @relation("ContactLeads") prospects Prospect[] @relation("ContactProspects") + tasks Task[] @relation("ContactTasks") @@index([organizationId]) @@index([isHidden]) @@ -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]) diff --git a/libs/sales/src/lib/repositories/task.repository.person-reference.spec.ts b/libs/sales/src/lib/repositories/task.repository.person-reference.spec.ts new file mode 100644 index 0000000..bbad218 --- /dev/null +++ b/libs/sales/src/lib/repositories/task.repository.person-reference.spec.ts @@ -0,0 +1,400 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { BadRequestException } from '@nestjs/common'; +import type { EventEmitter2 } from '@nestjs/event-emitter'; +import type { Contact, Organization, Prospect } from '@prisma/client'; +import { PrismaClient } from '@prisma/client'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { TaskRepository } from './task.repository'; +import { ContactsRepository } from './contacts.repository'; +import { ProspectsRepository } from './prospects.repository'; +import { TaskService } from '../services/task.service'; +import type { PrismaService } from '../modules/prisma.types'; + +// Exercises the real repository transactions against a migrated database. +// Requires DATABASE_URL pointing at a disposable database with the +// task contact_id/prospect_id migration applied. +describe.skipIf(!process.env.DATABASE_URL)( + 'TaskRepository person reference (database)', + () => { + let prisma: PrismaClient; + let repo: TaskRepository; + let service: TaskService; + let org: Organization; + let otherOrg: Organization; + let contact: Contact; + let otherContact: Contact; + let prospect: Prospect; + let otherProspect: Prospect; + const suffix = `${Date.now()}-${Math.floor(Math.random() * 1e6)}`; + const MISSING_ID = 2_000_000_000; + + const refsOf = async (taskId: number) => + prisma.task.findUniqueOrThrow({ + where: { id: taskId }, + select: { title: true, contactId: true, prospectId: true }, + }); + + beforeAll(async () => { + prisma = new PrismaClient({ + adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL! }), + }); + await prisma.$connect(); + repo = new TaskRepository(prisma as unknown as PrismaService); + service = new TaskService(repo, { + emitAsync: async () => [], + } as unknown as EventEmitter2); + + const mkOrg = (key: string) => + prisma.organization.create({ + data: { + name: `Person Ref ${key} ${suffix}`, + slug: `person-ref-${key}-${suffix}`, + createdAt: new Date(), + }, + }); + org = await mkOrg('org'); + otherOrg = await mkOrg('other'); + + contact = await prisma.contact.create({ + data: { + organizationId: org.id, + name: `Contact ${suffix}`, + email: `contact-${suffix}@example.com`, + }, + }); + otherContact = await prisma.contact.create({ + data: { organizationId: otherOrg.id, name: `Foreign ${suffix}` }, + }); + // Already resolved to `contact`; tasks must still never infer one from the other. + prospect = await prisma.prospect.create({ + data: { + organizationId: org.id, + name: `Prospect ${suffix}`, + email: `prospect-${suffix}@example.com`, + contactId: contact.id, + }, + }); + otherProspect = await prisma.prospect.create({ + data: { organizationId: otherOrg.id, name: `Foreign P ${suffix}` }, + }); + }); + + afterAll(async () => { + if (!prisma) return; + try { + const orgIds = [org?.id, otherOrg?.id].filter(Boolean) as number[]; + await prisma.task.deleteMany({ + where: { organizationId: { in: orgIds }, parentId: { not: null } }, + }); + await prisma.task.deleteMany({ + where: { organizationId: { in: orgIds } }, + }); + // Contacts and prospects cascade from their organization. + await prisma.organization.deleteMany({ where: { id: { in: orgIds } } }); + } finally { + await prisma.$disconnect(); + } + }); + + it('creates with a contact and reads it back on detail and list with an id/name summary only', async () => { + const created = await service.createTask(org.id, { + title: 'Linked contact', + contactId: contact.id, + }); + expect(created.contactId).toBe(contact.id); + expect(created.prospectId).toBeNull(); + expect(created.contact).toEqual({ id: contact.id, name: contact.name }); + expect(created.prospect).toBeNull(); + + const detail = await service.getTaskById(org.id, created.id); + expect(detail.contact).toEqual({ id: contact.id, name: contact.name }); + + const { tasks } = await service.getTasks(org.id, { + search: 'Linked contact', + }); + const listed = tasks.find((t) => t.id === created.id); + expect(listed?.contactId).toBe(contact.id); + expect(listed?.contact).toEqual({ id: contact.id, name: contact.name }); + expect(listed?.prospect).toBeNull(); + }); + + it('creates with a prospect and never converts it to its matched contact', async () => { + const created = await repo.create({ + organizationId: org.id, + title: 'Linked prospect', + prospectId: prospect.id, + }); + expect(created.prospectId).toBe(prospect.id); + expect(created.contactId).toBeNull(); + expect(created.prospect).toEqual({ + id: prospect.id, + name: prospect.name, + }); + expect(created.contact).toBeNull(); + }); + + it('creates without a reference when both are omitted', async () => { + const created = await repo.create({ + organizationId: org.id, + title: 'No reference', + }); + expect(created.contactId).toBeNull(); + expect(created.prospectId).toBeNull(); + expect(created.contact).toBeNull(); + expect(created.prospect).toBeNull(); + }); + + it('replaces one kind of reference with the other, preserves on omission, and clears on null', async () => { + const task = await repo.create({ + organizationId: org.id, + title: 'Replace', + contactId: contact.id, + }); + + const toProspect = await repo.update(task.id, org.id, { + prospectId: prospect.id, + }); + expect(toProspect.prospectId).toBe(prospect.id); + expect(toProspect.contactId).toBeNull(); + + const untouched = await service.updateTask(org.id, task.id, { + title: 'Replace renamed', + }); + expect(untouched.prospectId).toBe(prospect.id); + expect(untouched.contactId).toBeNull(); + + const toContact = await repo.update(task.id, org.id, { + contactId: contact.id, + }); + expect(toContact.contactId).toBe(contact.id); + expect(toContact.prospectId).toBeNull(); + + const clearedProspectOnly = await repo.update(task.id, org.id, { + prospectId: null, + }); + expect(clearedProspectOnly.contactId).toBe(contact.id); + + const cleared = await repo.update(task.id, org.id, { contactId: null }); + expect(cleared.contactId).toBeNull(); + expect(cleared.contact).toBeNull(); + + await repo.update(task.id, org.id, { prospectId: prospect.id }); + const clearedAll = await repo.update(task.id, org.id, { + contactId: null, + prospectId: null, + }); + expect(clearedAll.contactId).toBeNull(); + expect(clearedAll.prospectId).toBeNull(); + expect(clearedAll.title).toBe('Replace renamed'); + }); + + it('rejects both references even when the prospect maps to that contact, writing nothing', async () => { + const task = await repo.create({ + organizationId: org.id, + title: 'Both', + contactId: contact.id, + }); + + await expect( + service.updateTask(org.id, task.id, { + title: 'Both changed', + contactId: contact.id, + prospectId: prospect.id, + }), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + repo.create({ + organizationId: org.id, + title: 'Both create', + contactId: contact.id, + prospectId: prospect.id, + }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(await refsOf(task.id)).toEqual({ + title: 'Both', + contactId: contact.id, + prospectId: null, + }); + expect( + await prisma.task.count({ + where: { organizationId: org.id, title: 'Both create' }, + }), + ).toBe(0); + }); + + it.each([ + ['foreign contact', () => ({ contactId: otherContact.id })], + ['foreign prospect', () => ({ prospectId: otherProspect.id })], + ['nonexistent contact', () => ({ contactId: MISSING_ID })], + ['nonexistent prospect', () => ({ prospectId: MISSING_ID })], + ])('rejects a %s and rolls back the other fields', async (label, refs) => { + const task = await repo.create({ + organizationId: org.id, + title: `Rollback ${label}`, + prospectId: prospect.id, + }); + + await expect( + service.updateTask(org.id, task.id, { + title: 'Should not persist', + isSales: true, + ...refs(), + }), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + service.createTask(org.id, { + title: `Rejected create ${label}`, + ...refs(), + }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(await refsOf(task.id)).toEqual({ + title: `Rollback ${label}`, + contactId: null, + prospectId: prospect.id, + }); + const after = await repo.findById(task.id, org.id); + expect(after?.isSales).toBe(false); + expect( + await prisma.task.count({ + where: { title: `Rejected create ${label}` }, + }), + ).toBe(0); + }); + + it('ignores caller-supplied nested relation operations that would bypass org validation', async () => { + // The controller spreads the request body into the service at runtime, + // so these keys can reach the repository despite the static types. + const created = await service.createTask(org.id, { + title: 'Injected create', + contact: { connect: { id: otherContact.id } }, + prospect: { connect: { id: otherProspect.id } }, + } as never); + expect(await refsOf(created.id)).toEqual({ + title: 'Injected create', + contactId: null, + prospectId: null, + }); + + const updated = await service.updateTask(org.id, created.id, { + title: 'Injected update', + contact: { connect: { id: otherContact.id } }, + prospect: { + create: { organizationId: otherOrg.id, name: `Injected ${suffix}` }, + }, + organizationId: otherOrg.id, + } as never); + expect(updated.organizationId).toBe(org.id); + expect(await refsOf(created.id)).toEqual({ + title: 'Injected update', + contactId: null, + prospectId: null, + }); + expect( + await prisma.prospect.count({ where: { name: `Injected ${suffix}` } }), + ).toBe(0); + }); + + it('links a hidden contact, matching contact detail access within the organization', async () => { + const hidden = await prisma.contact.create({ + data: { + organizationId: org.id, + name: `Hidden ${suffix}`, + isHidden: true, + }, + }); + const task = await repo.create({ + organizationId: org.id, + title: 'Hidden link', + contactId: hidden.id, + }); + expect(task.contact).toEqual({ id: hidden.id, name: hidden.name }); + }); + + it('keeps the task and nulls the link when the person is deleted', async () => { + const doomedContact = await prisma.contact.create({ + data: { organizationId: org.id, name: `Doomed C ${suffix}` }, + }); + const doomedProspect = await prisma.prospect.create({ + data: { organizationId: org.id, name: `Doomed P ${suffix}` }, + }); + const withContact = await repo.create({ + organizationId: org.id, + title: 'Survives contact delete', + contactId: doomedContact.id, + }); + const withProspect = await repo.create({ + organizationId: org.id, + title: 'Survives prospect delete', + prospectId: doomedProspect.id, + }); + + await prisma.contact.delete({ where: { id: doomedContact.id } }); + await prisma.prospect.delete({ where: { id: doomedProspect.id } }); + + expect(await refsOf(withContact.id)).toEqual({ + title: 'Survives contact delete', + contactId: null, + prospectId: null, + }); + expect(await refsOf(withProspect.id)).toEqual({ + title: 'Survives prospect delete', + contactId: null, + prospectId: null, + }); + }); + + it('enforces a single reference in the database itself', async () => { + const task = await repo.create({ + organizationId: org.id, + title: 'Check constraint', + }); + await expect( + prisma.task.update({ + where: { id: task.id }, + data: { contactId: contact.id, prospectId: prospect.id }, + }), + ).rejects.toThrow(); + expect(await refsOf(task.id)).toEqual({ + title: 'Check constraint', + contactId: null, + prospectId: null, + }); + }); + + it('reads tasks written without references as having none', async () => { + const legacy = await prisma.task.create({ + data: { organizationId: org.id, title: 'Pre-existing task' }, + }); + const read = await repo.findById(legacy.id, org.id); + expect(read?.contactId).toBeNull(); + expect(read?.prospectId).toBeNull(); + expect(read?.contact).toBeNull(); + expect(read?.prospect).toBeNull(); + }); + + it('creating a contact or prospect never creates a task', async () => { + const before = await prisma.task.count({ + where: { organizationId: org.id }, + }); + const contacts = new ContactsRepository( + prisma as unknown as PrismaService, + ); + const prospects = new ProspectsRepository( + prisma as unknown as PrismaService, + ); + await contacts.create({ + organizationId: org.id, + name: `Fresh C ${suffix}`, + }); + await prospects.create({ + organizationId: org.id, + name: `Fresh P ${suffix}`, + }); + expect( + await prisma.task.count({ where: { organizationId: org.id } }), + ).toBe(before); + }); + }, +); diff --git a/libs/sales/src/lib/repositories/task.repository.spec.ts b/libs/sales/src/lib/repositories/task.repository.spec.ts index 2396be0..eaacf84 100644 --- a/libs/sales/src/lib/repositories/task.repository.spec.ts +++ b/libs/sales/src/lib/repositories/task.repository.spec.ts @@ -39,12 +39,20 @@ describe('TaskRepository', () => { member: { findFirst: vi.fn(), }, + contact: { + findFirst: vi.fn(), + }, + prospect: { + findFirst: vi.fn(), + }, $transaction: vi.fn( async (fn: (tx: typeof mockPrisma) => Promise) => fn({ task: mockPrisma.task, taskOwner: mockPrisma.taskOwner, member: mockPrisma.member, + contact: mockPrisma.contact, + prospect: mockPrisma.prospect, } as never), ), }; @@ -294,6 +302,182 @@ describe('TaskRepository', () => { }); }); + describe('person reference', () => { + const dataOf = (mock: Mock) => mock.mock.calls[0][0].data; + + it('create links an org-scoped contact in the same transaction', async () => { + (mockPrisma.contact.findFirst as Mock).mockResolvedValue({ id: 5 }); + (mockPrisma.task.create as Mock).mockResolvedValue(mockTask); + + await repo.create({ organizationId: ORG_ID, title: 'x', contactId: 5 }); + + expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1); + expect(mockPrisma.contact.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 5, organizationId: ORG_ID } }), + ); + expect(dataOf(mockPrisma.task.create as Mock)).toEqual({ + title: 'x', + organizationId: ORG_ID, + contactId: 5, + prospectId: null, + }); + }); + + it('create links an org-scoped prospect without touching contacts', async () => { + (mockPrisma.prospect.findFirst as Mock).mockResolvedValue({ id: 8 }); + (mockPrisma.task.create as Mock).mockResolvedValue(mockTask); + + await repo.create({ organizationId: ORG_ID, title: 'x', prospectId: 8 }); + + expect(mockPrisma.prospect.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 8, organizationId: ORG_ID } }), + ); + expect(mockPrisma.contact.findFirst).not.toHaveBeenCalled(); + expect(dataOf(mockPrisma.task.create as Mock)).toEqual({ + title: 'x', + organizationId: ORG_ID, + prospectId: 8, + contactId: null, + }); + }); + + it('create without references writes no reference columns', async () => { + (mockPrisma.task.create as Mock).mockResolvedValue(mockTask); + + await repo.create({ organizationId: ORG_ID, title: 'x' }); + + expect(mockPrisma.contact.findFirst).not.toHaveBeenCalled(); + expect(mockPrisma.prospect.findFirst).not.toHaveBeenCalled(); + expect(dataOf(mockPrisma.task.create as Mock)).toEqual({ + title: 'x', + organizationId: ORG_ID, + }); + }); + + it.each([ + ['contact', { contactId: 99 }], + ['prospect', { prospectId: 99 }], + ] as const)( + 'rejects a %s outside the organization without writing', + async (model, refs) => { + (mockPrisma[model].findFirst as Mock).mockResolvedValue(null); + + await expect( + repo.create({ organizationId: ORG_ID, title: 'x', ...refs }), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + repo.update(1, ORG_ID, { title: 'x', ...refs }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(mockPrisma.task.create).not.toHaveBeenCalled(); + expect(mockPrisma.task.update).not.toHaveBeenCalled(); + expect(mockPrisma.taskOwner.create).not.toHaveBeenCalled(); + }, + ); + + it('rejects both references at once without lookups or writes', async () => { + await expect( + repo.create({ + organizationId: ORG_ID, + title: 'x', + contactId: 5, + prospectId: 8, + }), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + repo.update(1, ORG_ID, { contactId: 5, prospectId: 8 }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(mockPrisma.contact.findFirst).not.toHaveBeenCalled(); + expect(mockPrisma.prospect.findFirst).not.toHaveBeenCalled(); + expect(mockPrisma.task.create).not.toHaveBeenCalled(); + expect(mockPrisma.task.update).not.toHaveBeenCalled(); + }); + + it('update setting a contact clears any prior prospect', async () => { + (mockPrisma.contact.findFirst as Mock).mockResolvedValue({ id: 5 }); + (mockPrisma.task.update as Mock).mockResolvedValue(mockTask); + + await repo.update(1, ORG_ID, { contactId: 5 }); + + expect(dataOf(mockPrisma.task.update as Mock)).toEqual({ + contactId: 5, + prospectId: null, + }); + }); + + it('update setting a prospect clears any prior contact', async () => { + (mockPrisma.prospect.findFirst as Mock).mockResolvedValue({ id: 8 }); + (mockPrisma.task.update as Mock).mockResolvedValue(mockTask); + + await repo.update(1, ORG_ID, { prospectId: 8, contactId: null }); + + expect(dataOf(mockPrisma.task.update as Mock)).toEqual({ + prospectId: 8, + contactId: null, + }); + }); + + it.each([ + [{ contactId: null }, { contactId: null }], + [{ prospectId: null }, { prospectId: null }], + [ + { contactId: null, prospectId: null }, + { contactId: null, prospectId: null }, + ], + ])('update %j clears only what was sent', async (input, expected) => { + (mockPrisma.task.update as Mock).mockResolvedValue(mockTask); + + await repo.update(1, ORG_ID, input); + + expect(mockPrisma.contact.findFirst).not.toHaveBeenCalled(); + expect(mockPrisma.prospect.findFirst).not.toHaveBeenCalled(); + expect(dataOf(mockPrisma.task.update as Mock)).toEqual(expected); + }); + + it('includes only id and name of the linked person', async () => { + (mockPrisma.task.findFirst as Mock).mockResolvedValue(mockTask); + + await repo.findById(1, ORG_ID); + + expect(mockPrisma.task.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + include: expect.objectContaining({ + contact: { select: { id: true, name: true } }, + prospect: { select: { id: true, name: true } }, + }), + }), + ); + }); + + it('ignores caller-supplied relation operations and org changes', async () => { + (mockPrisma.task.create as Mock).mockResolvedValue(mockTask); + (mockPrisma.task.update as Mock).mockResolvedValue(mockTask); + const injected = { + contact: { connect: { id: 99 } }, + prospect: { create: { name: 'Injected', organizationId: 2 } }, + owners: { create: [{ userId: 99 }] }, + organization: { connect: { id: 2 } }, + assigneeUser: { connect: { id: 99 } }, + }; + + await repo.create({ + organizationId: ORG_ID, + title: 'x', + ...injected, + } as never); + await repo.update(1, ORG_ID, { + title: 'y', + organizationId: 2, + ...injected, + } as never); + + expect(dataOf(mockPrisma.task.create as Mock)).toEqual({ + title: 'x', + organizationId: ORG_ID, + }); + expect(dataOf(mockPrisma.task.update as Mock)).toEqual({ title: 'y' }); + }); + }); + describe('delete', () => { it('should delete a task by id', async () => { (mockPrisma.task.delete as Mock).mockResolvedValue(mockTask as never); diff --git a/libs/sales/src/lib/repositories/task.repository.ts b/libs/sales/src/lib/repositories/task.repository.ts index ec0a99f..c6b872a 100644 --- a/libs/sales/src/lib/repositories/task.repository.ts +++ b/libs/sales/src/lib/repositories/task.repository.ts @@ -19,6 +19,9 @@ export interface CreateTaskInput { /** Legacy display name. Ignored when assigneeUserId is provided. */ assignee?: string; assigneeUserId?: number | null; + /** At most one of contactId/prospectId may be set. */ + contactId?: number | null; + prospectId?: number | null; createdByUserId?: number; } @@ -33,16 +36,49 @@ export interface UpdateTaskInput { /** Legacy display name. Ignored when assigneeUserId is provided. */ assignee?: string | null; assigneeUserId?: number | null; + /** + * Setting one person reference clears the other; null clears it; omit to + * keep it. Sending both non-null is rejected. + */ + contactId?: number | null; + prospectId?: number | null; } type AssignmentInput = Pick; +type PersonReferenceInput = Pick; + +// Only these plain columns are copied from caller input. Callers spread request +// bodies into the input at runtime, so anything else (nested relation writes, +// organizationId) would otherwise reach Prisma and bypass org validation. +const writableFields = [ + 'title', + 'description', + 'status', + 'priority', + 'isSales', + 'completedAt', + 'parentId', +] as const; + +type WritableFields = Pick; + +function pickWritableFields(input: object): WritableFields { + return Object.fromEntries( + Object.entries(input).filter(([key]) => + (writableFields as readonly string[]).includes(key), + ), + ) as WritableFields; +} const userSummary = { select: { id: true, name: true, email: true } } as const; +const personSummary = { select: { id: true, name: true } } as const; const taskInclude = { subtasks: true, owners: { include: { user: userSummary } }, assigneeUser: userSummary, + contact: personSummary, + prospect: personSummary, } as const; @Injectable() @@ -79,22 +115,79 @@ export class TaskRepository { return { assigneeUserId: member.user.id, assignee: member.user.name }; } + /** + * Maps the optional person reference to columns. The person must belong to + * the organization; a prospect is never swapped for its matched contact. + */ + private async resolvePersonReference( + tx: Prisma.TransactionClient, + organizationId: number, + { contactId, prospectId }: PersonReferenceInput, + ): Promise { + if (contactId != null && prospectId != null) { + throw new BadRequestException( + 'A task can reference a contact or a prospect, not both', + ); + } + if (contactId != null) { + const contact = await tx.contact.findFirst({ + where: { id: contactId, organizationId }, + select: { id: true }, + }); + if (!contact) { + throw new BadRequestException( + `Contact ${contactId} does not exist in this organization`, + ); + } + return { contactId, prospectId: null }; + } + if (prospectId != null) { + const prospect = await tx.prospect.findFirst({ + where: { id: prospectId, organizationId }, + select: { id: true }, + }); + if (!prospect) { + throw new BadRequestException( + `Prospect ${prospectId} does not exist in this organization`, + ); + } + return { prospectId, contactId: null }; + } + return { + ...(contactId === null ? { contactId: null } : {}), + ...(prospectId === null ? { prospectId: null } : {}), + }; + } + async create(input: CreateTaskInput) { const { organizationId, createdByUserId, assignee, assigneeUserId, - ...data + contactId, + prospectId, } = input; + const data = pickWritableFields(input); return this.prisma.$transaction(async (tx) => { + const personReference = await this.resolvePersonReference( + tx, + organizationId, + { contactId, prospectId }, + ); const assignment = await this.resolveAssignment(tx, organizationId, { assignee, assigneeUserId, }); const task = await tx.task.create({ - data: { ...data, organizationId, ...assignment }, + data: { + ...data, + title: input.title, + organizationId, + ...assignment, + ...personReference, + }, include: taskInclude, }); @@ -190,16 +283,22 @@ export class TaskRepository { } async update(id: number, organizationId: number, input: UpdateTaskInput) { - const { assignee, assigneeUserId, ...data } = input; + const { assignee, assigneeUserId, contactId, prospectId } = input; + const data = pickWritableFields(input); return this.prisma.$transaction(async (tx) => { + const personReference = await this.resolvePersonReference( + tx, + organizationId, + { contactId, prospectId }, + ); const assignment = await this.resolveAssignment(tx, organizationId, { assignee, assigneeUserId, }); return tx.task.update({ where: { id, organizationId }, - data: { ...data, ...assignment }, + data: { ...data, ...assignment, ...personReference }, include: taskInclude, }); });