diff --git a/apps/api/package.json b/apps/api/package.json index 83645f9..f851c23 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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": "*", diff --git a/apps/api/src/organizations/dto.ts b/apps/api/src/organizations/dto.ts index 5fd09d9..ff15960 100644 --- a/apps/api/src/organizations/dto.ts +++ b/apps/api/src/organizations/dto.ts @@ -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; +} diff --git a/apps/api/src/organizations/organizations.controller.ts b/apps/api/src/organizations/organizations.controller.ts index 10e8331..e9e2ec0 100644 --- a/apps/api/src/organizations/organizations.controller.ts +++ b/apps/api/src/organizations/organizations.controller.ts @@ -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, + ); + } } diff --git a/apps/api/src/organizations/organizations.service.ts b/apps/api/src/organizations/organizations.service.ts index 9b7a363..9393282 100644 --- a/apps/api/src/organizations/organizations.service.ts +++ b/apps/api/src/organizations/organizations.service.ts @@ -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 { + 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), diff --git a/apps/api/test/organizations.test.ts b/apps/api/test/organizations.test.ts new file mode 100644 index 0000000..8105bc0 --- /dev/null +++ b/apps/api/test/organizations.test.ts @@ -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 = {}) { + 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 = []; + 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]); + }); +});