Implement prior archive cutoff API

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-17 17:01:52 +03:30
co-authored by Cursor
parent 79ba146118
commit 65d66bb331
5 changed files with 199 additions and 2 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
"prisma:seed": "tsx prisma/seed.ts",
"lint": "echo \"api lint placeholder\"",
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json",
"test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/birthdays.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts test/pipeline-merge.test.ts test/ask-count.test.ts test/prior-archive.test.ts"
"test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/birthdays.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts test/pipeline-merge.test.ts test/ask-count.test.ts test/prior-archive.test.ts test/organizations.test.ts"
},
"dependencies": {
"@mento/shared": "*",
+7
View File
@@ -12,6 +12,7 @@ import {
} from 'class-validator';
import { Type } from 'class-transformer';
import { BUSINESS_FIELDS } from '@mento/shared';
import type { UpdatePriorArchiveCutoffRequest } from '@mento/shared';
export class ExchangeRateInputDto {
@IsString()
@@ -67,3 +68,9 @@ export class UpdateOrganizationDto {
@IsIn([...BUSINESS_FIELDS])
businessField?: (typeof BUSINESS_FIELDS)[number];
}
export class UpdatePriorArchiveCutoffDto
implements UpdatePriorArchiveCutoffRequest
{
cutoffAt!: string | null;
}
@@ -1,4 +1,12 @@
import { Body, Controller, Get, Patch, Post, UseGuards } from '@nestjs/common';
import {
BadRequestException,
Body,
Controller,
Get,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
@@ -6,6 +14,7 @@ import { CurrentUser } from '../auth/current-user.decorator';
import { AuthService } from '../auth/auth.service';
import {
CreateOrganizationDto,
UpdatePriorArchiveCutoffDto,
UpdateOrgCurrenciesDto,
UpdateOrganizationDto,
} from './dto';
@@ -58,4 +67,19 @@ export class OrganizationsController {
dto,
);
}
@Patch('current/prior-archive-cutoff')
@Roles('owner')
updatePriorArchiveCutoff(
@CurrentUser() user: { organizationId: string },
@Body() dto: UpdatePriorArchiveCutoffDto,
) {
if (!Object.prototype.hasOwnProperty.call(dto ?? {}, 'cutoffAt')) {
throw new BadRequestException('تاریخ قطع باید مشخص شود.');
}
return this.organizationsService.updatePriorArchiveCutoff(
user.organizationId,
dto.cutoffAt,
);
}
}
@@ -7,6 +7,7 @@ import {
import type {
OrganizationDto,
UpdateOrgCurrenciesRequest,
UpdatePriorArchiveCutoffRequest,
} from '@mento/shared';
import { CURRENCY_CATALOG, isBusinessField } from '@mento/shared';
import { Prisma } from '@prisma/client';
@@ -214,6 +215,39 @@ export class OrganizationsService {
return this.getCurrent(organizationId);
}
async updatePriorArchiveCutoff(
organizationId: string,
cutoffAt: UpdatePriorArchiveCutoffRequest['cutoffAt'],
): Promise<OrganizationDto> {
const org = await this.prisma.organization.findUnique({
where: { id: organizationId },
select: { enabledModules: true },
});
if (!org) throw new NotFoundException('سازمان پیدا نشد');
const modules = this.billing.normalizeModules(org.enabledModules);
if (!modules.includes('prior_data_archive')) {
throw new BadRequestException(
'ماژول بایگانی اطلاعات قبل برای این محیط فعال نیست.',
);
}
let value: Date | null = null;
if (cutoffAt !== null && cutoffAt !== '') {
const parsed = new Date(cutoffAt);
if (Number.isNaN(parsed.getTime())) {
throw new BadRequestException('تاریخ قطع معتبر نیست.');
}
value = parsed;
}
await this.prisma.organization.update({
where: { id: organizationId },
data: { priorArchiveEnabledAt: value },
});
return this.getCurrent(organizationId);
}
catalog() {
return CURRENCY_CATALOG;
}
@@ -226,6 +260,7 @@ export class OrganizationsService {
baseCurrency: string;
enabledCurrencies: string[];
enabledModules?: string[];
priorArchiveEnabledAt?: Date | null;
exchangeRates: Array<{
currency: string;
rateToBase: Prisma.Decimal;
@@ -243,6 +278,7 @@ export class OrganizationsService {
baseCurrency: org.baseCurrency,
enabledCurrencies: org.enabledCurrencies,
enabledModules: this.billing.normalizeModules(org.enabledModules),
priorArchiveCutoffAt: org.priorArchiveEnabledAt?.toISOString() ?? null,
exchangeRates: org.exchangeRates.map((r) => ({
currency: r.currency,
rateToBase: Number(r.rateToBase),
+130
View File
@@ -0,0 +1,130 @@
import 'reflect-metadata';
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { BadRequestException } from '@nestjs/common';
import type { UpdatePriorArchiveCutoffRequest } from '@mento/shared';
import { ROLES_KEY } from '../src/auth/roles.decorator';
import { OrganizationsController } from '../src/organizations/organizations.controller';
import { OrganizationsService } from '../src/organizations/organizations.service';
const cutoff = new Date('2026-10-01T00:00:00.000Z');
function makeOrg(overrides: Record<string, unknown> = {}) {
return {
id: 'org-1',
name: 'Org',
slug: 'org',
businessField: 'personal',
baseCurrency: 'IRR',
enabledCurrencies: ['IRR'],
enabledModules: ['prior_data_archive'],
priorArchiveEnabledAt: cutoff,
exchangeRates: [],
...overrides,
};
}
function makeService(org = makeOrg()) {
const calls: Array<{ where: unknown; data: unknown }> = [];
const prisma = {
organization: {
findUnique: async () => org,
update: async (args: { where: unknown; data: unknown }) => {
calls.push(args);
return org;
},
},
};
const billing = {
normalizeModules: (modules?: string[]) => modules ?? [],
};
const service = new OrganizationsService(
prisma as never,
{} as never,
{} as never,
{} as never,
billing as never,
);
return { service, calls };
}
describe('OrganizationsService prior archive cutoff', () => {
it('returns the current cutoff in the organization dto', async () => {
const { service } = makeService();
const dto = await service.getCurrent('org-1');
assert.equal(dto.priorArchiveCutoffAt, cutoff.toISOString());
});
it('updates and clears the cutoff when prior archive is enabled', async () => {
const { service, calls } = makeService();
await service.updatePriorArchiveCutoff('org-1', '2026-11-02T00:00:00.000Z');
await service.updatePriorArchiveCutoff('org-1', null);
assert.deepEqual(calls.map((call) => call.data), [
{ priorArchiveEnabledAt: new Date('2026-11-02T00:00:00.000Z') },
{ priorArchiveEnabledAt: null },
]);
});
it('rejects invalid cutoffs and disabled modules', async () => {
const enabled = makeService();
await assert.rejects(
() => enabled.service.updatePriorArchiveCutoff('org-1', 'not-a-date'),
BadRequestException,
);
const disabled = makeService(makeOrg({ enabledModules: [] }));
await assert.rejects(
() =>
disabled.service.updatePriorArchiveCutoff(
'org-1',
'2026-11-02T00:00:00.000Z',
),
BadRequestException,
);
});
});
describe('OrganizationsController prior archive cutoff', () => {
it('exposes an owner-only explicit cutoff patch', async () => {
const calls: Array<UpdatePriorArchiveCutoffRequest['cutoffAt']> = [];
const controller = new OrganizationsController(
{
updatePriorArchiveCutoff: async (
_organizationId: string,
cutoffAt: UpdatePriorArchiveCutoffRequest['cutoffAt'],
) => {
calls.push(cutoffAt);
return makeOrg();
},
} as never,
{} as never,
);
assert.deepEqual(
Reflect.getMetadata(
ROLES_KEY,
controller.updatePriorArchiveCutoff,
),
['owner'],
);
assert.throws(
() =>
controller.updatePriorArchiveCutoff(
{ organizationId: 'org-1' },
{} as UpdatePriorArchiveCutoffRequest,
),
BadRequestException,
);
await controller.updatePriorArchiveCutoff(
{ organizationId: 'org-1' },
{ cutoffAt: null },
);
assert.deepEqual(calls, [null]);
});
});