Implement prior archive cutoff API
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
"prisma:seed": "tsx prisma/seed.ts",
|
"prisma:seed": "tsx prisma/seed.ts",
|
||||||
"lint": "echo \"api lint placeholder\"",
|
"lint": "echo \"api lint placeholder\"",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json",
|
"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": {
|
"dependencies": {
|
||||||
"@mento/shared": "*",
|
"@mento/shared": "*",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { BUSINESS_FIELDS } from '@mento/shared';
|
import { BUSINESS_FIELDS } from '@mento/shared';
|
||||||
|
import type { UpdatePriorArchiveCutoffRequest } from '@mento/shared';
|
||||||
|
|
||||||
export class ExchangeRateInputDto {
|
export class ExchangeRateInputDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -67,3 +68,9 @@ export class UpdateOrganizationDto {
|
|||||||
@IsIn([...BUSINESS_FIELDS])
|
@IsIn([...BUSINESS_FIELDS])
|
||||||
businessField?: (typeof BUSINESS_FIELDS)[number];
|
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 { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { RolesGuard } from '../auth/roles.guard';
|
import { RolesGuard } from '../auth/roles.guard';
|
||||||
import { Roles } from '../auth/roles.decorator';
|
import { Roles } from '../auth/roles.decorator';
|
||||||
@@ -6,6 +14,7 @@ import { CurrentUser } from '../auth/current-user.decorator';
|
|||||||
import { AuthService } from '../auth/auth.service';
|
import { AuthService } from '../auth/auth.service';
|
||||||
import {
|
import {
|
||||||
CreateOrganizationDto,
|
CreateOrganizationDto,
|
||||||
|
UpdatePriorArchiveCutoffDto,
|
||||||
UpdateOrgCurrenciesDto,
|
UpdateOrgCurrenciesDto,
|
||||||
UpdateOrganizationDto,
|
UpdateOrganizationDto,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
@@ -58,4 +67,19 @@ export class OrganizationsController {
|
|||||||
dto,
|
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 {
|
import type {
|
||||||
OrganizationDto,
|
OrganizationDto,
|
||||||
UpdateOrgCurrenciesRequest,
|
UpdateOrgCurrenciesRequest,
|
||||||
|
UpdatePriorArchiveCutoffRequest,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { CURRENCY_CATALOG, isBusinessField } from '@mento/shared';
|
import { CURRENCY_CATALOG, isBusinessField } from '@mento/shared';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
@@ -214,6 +215,39 @@ export class OrganizationsService {
|
|||||||
return this.getCurrent(organizationId);
|
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() {
|
catalog() {
|
||||||
return CURRENCY_CATALOG;
|
return CURRENCY_CATALOG;
|
||||||
}
|
}
|
||||||
@@ -226,6 +260,7 @@ export class OrganizationsService {
|
|||||||
baseCurrency: string;
|
baseCurrency: string;
|
||||||
enabledCurrencies: string[];
|
enabledCurrencies: string[];
|
||||||
enabledModules?: string[];
|
enabledModules?: string[];
|
||||||
|
priorArchiveEnabledAt?: Date | null;
|
||||||
exchangeRates: Array<{
|
exchangeRates: Array<{
|
||||||
currency: string;
|
currency: string;
|
||||||
rateToBase: Prisma.Decimal;
|
rateToBase: Prisma.Decimal;
|
||||||
@@ -243,6 +278,7 @@ export class OrganizationsService {
|
|||||||
baseCurrency: org.baseCurrency,
|
baseCurrency: org.baseCurrency,
|
||||||
enabledCurrencies: org.enabledCurrencies,
|
enabledCurrencies: org.enabledCurrencies,
|
||||||
enabledModules: this.billing.normalizeModules(org.enabledModules),
|
enabledModules: this.billing.normalizeModules(org.enabledModules),
|
||||||
|
priorArchiveCutoffAt: org.priorArchiveEnabledAt?.toISOString() ?? null,
|
||||||
exchangeRates: org.exchangeRates.map((r) => ({
|
exchangeRates: org.exchangeRates.map((r) => ({
|
||||||
currency: r.currency,
|
currency: r.currency,
|
||||||
rateToBase: Number(r.rateToBase),
|
rateToBase: Number(r.rateToBase),
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user