From 008733d338fee898f9c746fbf981bad2f052f186 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sat, 17 Oct 2026 17:13:01 +0330 Subject: [PATCH] Add prior promotion endpoints Co-authored-by: Cursor --- .../src/activities/activities.controller.ts | 5 + apps/api/src/activities/activities.service.ts | 33 +++ apps/api/src/auth/auth.service.ts | 1 + apps/api/src/pipelines/deals.controller.ts | 5 + apps/api/src/pipelines/deals.service.ts | 35 +++- apps/api/test/promote-from-prior.test.ts | 194 ++++++++++++++++++ 6 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 apps/api/test/promote-from-prior.test.ts diff --git a/apps/api/src/activities/activities.controller.ts b/apps/api/src/activities/activities.controller.ts index 23d8302..b74f764 100644 --- a/apps/api/src/activities/activities.controller.ts +++ b/apps/api/src/activities/activities.controller.ts @@ -267,6 +267,11 @@ export class ActivitiesController { return this.activitiesService.get(user.organizationId, id, user); } + @Post(':id/promote-from-prior') + promoteFromPrior(@CurrentUser() user: Actor, @Param('id') id: string) { + return this.activitiesService.promoteFromPrior(user, id); + } + @Post() create(@CurrentUser() user: Actor, @Body() dto: CreateActivityDto) { return this.activitiesService.create( diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index 38971a6..83fbd9b 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; @@ -616,6 +617,38 @@ export class ActivitiesService { return this.toDto(row, await this.ctx(organizationId)); } + async promoteFromPrior(actor: Actor, id: string): Promise { + if ( + (actor.priorLens !== 'current' && actor.priorLens !== 'archive') || + !actor.priorCutoff + ) { + throw new ForbiddenException('بایگانی اطلاعات قبل برای شما فعال نیست.'); + } + + const row = await this.prisma.activity.findFirst({ + where: { + id, + organizationId: actor.organizationId, + ...activityAccessWhere(actor), + }, + select: { id: true, createdAt: true, promotedFromPriorAt: true }, + }); + if (!row) throw new NotFoundException('فعالیت پیدا نشد'); + if (row.promotedFromPriorAt) { + throw new BadRequestException('این فعالیت قبلاً منتقل شده است.'); + } + if (row.createdAt >= actor.priorCutoff) { + throw new BadRequestException('این فعالیت در بایگانی نیست.'); + } + + const updated = await this.prisma.activity.update({ + where: { id }, + data: { promotedFromPriorAt: new Date() }, + include, + }); + return this.toDto(updated, await this.ctx(actor.organizationId)); + } + async remove( organizationId: string, id: string, diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 8fb112e..c8ab25c 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -10,6 +10,7 @@ import { import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcryptjs'; +import type {} from 'multer'; import * as path from 'path'; import type { AuthUserDto, diff --git a/apps/api/src/pipelines/deals.controller.ts b/apps/api/src/pipelines/deals.controller.ts index 0f90ce6..aa803f3 100644 --- a/apps/api/src/pipelines/deals.controller.ts +++ b/apps/api/src/pipelines/deals.controller.ts @@ -93,6 +93,11 @@ export class DealsController { return this.dealsService.get(user.organizationId, id, user); } + @Post(':id/promote-from-prior') + promoteFromPrior(@CurrentUser() user: Actor, @Param('id') id: string) { + return this.dealsService.promoteFromPrior(user, id); + } + @Post() create(@CurrentUser() user: Actor, @Body() dto: CreateDealDto) { return this.dealsService.create(user.organizationId, user, dto); diff --git a/apps/api/src/pipelines/deals.service.ts b/apps/api/src/pipelines/deals.service.ts index d153853..5157d0f 100644 --- a/apps/api/src/pipelines/deals.service.ts +++ b/apps/api/src/pipelines/deals.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; @@ -38,7 +39,7 @@ const dealInclude = { type DealActor = Pick< Actor, - 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' + 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'priorLens' | 'priorCutoff' >; @Injectable() @@ -443,6 +444,38 @@ export class DealsService { return this.pipelines.toDealDto(row); } + async promoteFromPrior(actor: Actor, id: string): Promise { + if ( + (actor.priorLens !== 'current' && actor.priorLens !== 'archive') || + !actor.priorCutoff + ) { + throw new ForbiddenException('بایگانی اطلاعات قبل برای شما فعال نیست.'); + } + + const row = await this.prisma.deal.findFirst({ + where: { + id, + organizationId: actor.organizationId, + ...dealAccessWhere(actor), + }, + select: { id: true, createdAt: true, promotedFromPriorAt: true }, + }); + if (!row) throw new NotFoundException('معامله پیدا نشد'); + if (row.promotedFromPriorAt) { + throw new BadRequestException('این معامله قبلاً منتقل شده است.'); + } + if (row.createdAt >= actor.priorCutoff) { + throw new BadRequestException('این معامله در بایگانی نیست.'); + } + + const updated = await this.prisma.deal.update({ + where: { id }, + data: { promotedFromPriorAt: new Date() }, + include: dealInclude, + }); + return this.pipelines.toDealDto(updated); + } + async remove( organizationId: string, id: string, diff --git a/apps/api/test/promote-from-prior.test.ts b/apps/api/test/promote-from-prior.test.ts new file mode 100644 index 0000000..c1f563a --- /dev/null +++ b/apps/api/test/promote-from-prior.test.ts @@ -0,0 +1,194 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; +import { DealsService } from '../src/pipelines/deals.service'; +import { ActivitiesService } from '../src/activities/activities.service'; +import type { Actor } from '../src/common/record-scope'; + +const cutoff = new Date('2026-10-01T00:00:00.000Z'); +const priorCreatedAt = new Date('2026-09-01T00:00:00.000Z'); +const currentCreatedAt = new Date('2026-10-02T00:00:00.000Z'); +const promotedAt = new Date('2026-10-03T00:00:00.000Z'); + +const actor: Actor = { + userId: 'u1', + role: 'sales', + organizationId: 'org1', + priorLens: 'archive', + priorCutoff: cutoff, +}; + +function dealRow(overrides: Record = {}) { + return { + id: 'deal1', + organizationId: 'org1', + title: 'Prior deal', + createdAt: priorCreatedAt, + promotedFromPriorAt: null, + ...overrides, + }; +} + +type DealPromotionProbe = ReturnType | null; + +function activityRow(overrides: Record = {}) { + return { + id: 'act1', + organizationId: 'org1', + type: 'task', + status: 'open', + priority: 'medium', + title: 'Prior activity', + description: null, + dueAt: null, + durationMinutes: null, + reminderAt: null, + reminderNotifyApp: true, + reminderNotifySms: false, + reminderSentAt: null, + completedAt: null, + tags: [], + createdAt: priorCreatedAt, + updatedAt: priorCreatedAt, + promotedFromPriorAt: null, + person: null, + company: null, + deal: null, + lead: null, + assignee: null, + assignees: [], + createdBy: null, + participantIds: [], + ...overrides, + }; +} + +type ActivityPromotionProbe = ReturnType | null; + +describe('promote from prior', () => { + it('promotes an accessible prior deal by setting only promotedFromPriorAt', async () => { + const calls: unknown[] = []; + const prisma = { + deal: { + findFirst: async (args: unknown) => { + calls.push(['findFirst', args]); + return dealRow(); + }, + update: async (args: unknown) => { + calls.push(['update', args]); + return dealRow({ promotedFromPriorAt: promotedAt }); + }, + }, + }; + const pipelines = { + toDealDto: (row: { id: string; createdAt: Date; promotedFromPriorAt: Date | null }) => ({ + id: row.id, + createdAt: row.createdAt.toISOString(), + promotedFromPriorAt: row.promotedFromPriorAt?.toISOString() ?? null, + }), + }; + const service = new DealsService( + prisma as never, + pipelines as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); + + const dto = await service.promoteFromPrior(actor, 'deal1'); + + const updateArgs = (calls[1] as [string, { data: Record }])[1]; + assert.deepEqual(Object.keys(updateArgs.data), ['promotedFromPriorAt']); + assert.equal(dto.createdAt, priorCreatedAt.toISOString()); + assert.equal(dto.promotedFromPriorAt, promotedAt.toISOString()); + }); + + it('rejects deal promote when prior lens is off or cutoff missing', async () => { + const service = new DealsService({} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never); + + await assert.rejects( + service.promoteFromPrior({ ...actor, priorLens: 'off' }, 'deal1'), + ForbiddenException, + ); + await assert.rejects( + service.promoteFromPrior({ ...actor, priorCutoff: null }, 'deal1'), + ForbiddenException, + ); + }); + + it('rejects deal promote unless the record is unpromoted prior data', async () => { + const prisma = { + deal: { + findFirst: async (): Promise => + dealRow({ promotedFromPriorAt: promotedAt }), + }, + }; + const service = new DealsService(prisma as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never); + + await assert.rejects(service.promoteFromPrior(actor, 'deal1'), BadRequestException); + + prisma.deal.findFirst = async () => dealRow({ createdAt: currentCreatedAt }); + await assert.rejects(service.promoteFromPrior(actor, 'deal1'), BadRequestException); + + prisma.deal.findFirst = async () => null; + await assert.rejects(service.promoteFromPrior(actor, 'deal1'), NotFoundException); + }); + + it('promotes an accessible prior activity by setting only promotedFromPriorAt', async () => { + const calls: unknown[] = []; + const prisma = { + activity: { + findFirst: async (args: unknown) => { + calls.push(['findFirst', args]); + return activityRow(); + }, + update: async (args: unknown) => { + calls.push(['update', args]); + return activityRow({ promotedFromPriorAt: promotedAt }); + }, + }, + }; + const service = new ActivitiesService( + prisma as never, + {} as never, + {} as never, + {} as never, + { defaults: async () => ({}), effectiveStageId: () => null } as never, + { system: async () => ({}), toSummary: (row: unknown) => row } as never, + {} as never, + ); + + const dto = await service.promoteFromPrior(actor, 'act1'); + + const updateArgs = (calls[1] as [string, { data: Record }])[1]; + assert.deepEqual(Object.keys(updateArgs.data), ['promotedFromPriorAt']); + assert.equal(dto.createdAt, priorCreatedAt.toISOString()); + assert.equal(dto.promotedFromPriorAt, promotedAt.toISOString()); + }); + + it('rejects activity promote unless the actor and record are eligible', async () => { + const prisma = { + activity: { + findFirst: async (): Promise => + activityRow({ createdAt: currentCreatedAt }), + }, + }; + const service = new ActivitiesService( + prisma as never, + {} as never, + {} as never, + {} as never, + { defaults: async () => ({}), effectiveStageId: () => null } as never, + { system: async () => ({}), toSummary: (row: unknown) => row } as never, + {} as never, + ); + + await assert.rejects(service.promoteFromPrior({ ...actor, priorLens: 'off' }, 'act1'), ForbiddenException); + await assert.rejects(service.promoteFromPrior(actor, 'act1'), BadRequestException); + + prisma.activity.findFirst = async () => null; + await assert.rejects(service.promoteFromPrior(actor, 'act1'), NotFoundException); + }); +});