Add prior promotion endpoints
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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<ActivityDto> {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<DealDto> {
|
||||
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,
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'deal1',
|
||||
organizationId: 'org1',
|
||||
title: 'Prior deal',
|
||||
createdAt: priorCreatedAt,
|
||||
promotedFromPriorAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
type DealPromotionProbe = ReturnType<typeof dealRow> | null;
|
||||
|
||||
function activityRow(overrides: Record<string, unknown> = {}) {
|
||||
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<typeof activityRow> | 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<string, unknown> }])[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<DealPromotionProbe> =>
|
||||
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<string, unknown> }])[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<ActivityPromotionProbe> =>
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user