mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
add dispute functionality for contract duty and implement collection dates
This commit is contained in:
@@ -53,4 +53,16 @@ export class FileRecord extends BaseEntity {
|
||||
|
||||
@Column({ name: "reviewed_at", type: "timestamptz", nullable: true })
|
||||
reviewedAt!: Date | null;
|
||||
|
||||
/**
|
||||
* Who replaced this version, when a newer file took its place. Superseded
|
||||
* versions are soft-deleted rather than dropped, so the original a customer
|
||||
* uploaded survives a staff correction and the two can be compared.
|
||||
*/
|
||||
@Column({ name: "replaced_by_user_id", type: "uuid", nullable: true })
|
||||
replacedByUserId!: string | null;
|
||||
|
||||
/** Why the file was replaced — shown on the document's version history. */
|
||||
@Column({ name: "replace_reason", type: "text", nullable: true })
|
||||
replaceReason!: string | null;
|
||||
}
|
||||
|
||||
@@ -41,12 +41,47 @@ export class FilesRepository extends BaseRepository<FileRecord> {
|
||||
return this.repository.findOne({ where: { resourceId, resource, code } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire the live version(s) of a document code. SOFT delete on purpose: the
|
||||
* bytes and the row stay so the original upload can still be read back from
|
||||
* the version history after staff replace it. Every normal read already
|
||||
* filters soft-deleted rows, so callers see only the current version.
|
||||
*
|
||||
* `replacedBy` / `reason` are stamped on the retired row when a newer file is
|
||||
* taking its place (as opposed to a plain removal).
|
||||
*/
|
||||
async deleteByCode(
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
code: string,
|
||||
replacedBy?: { userId?: string | null; reason?: string | null },
|
||||
): Promise<void> {
|
||||
await this.repository.delete({ resourceId, resource, code });
|
||||
if (replacedBy) {
|
||||
await this.repository.update(
|
||||
{ resourceId, resource, code },
|
||||
{
|
||||
replacedByUserId: replacedBy.userId ?? null,
|
||||
replaceReason: replacedBy.reason ?? null,
|
||||
},
|
||||
);
|
||||
}
|
||||
await this.repository.softDelete({ resourceId, resource, code });
|
||||
}
|
||||
|
||||
/**
|
||||
* Every version of one document code, newest first — superseded versions
|
||||
* included. The only read that deliberately looks past the soft-delete filter.
|
||||
*/
|
||||
findVersionHistory(
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
code: string,
|
||||
): Promise<FileRecord[]> {
|
||||
return this.repository.find({
|
||||
where: { resourceId, resource, code },
|
||||
withDeleted: true,
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
112
apps/edr-freight-api/src/modules/files/files.service.spec.ts
Normal file
112
apps/edr-freight-api/src/modules/files/files.service.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { FilesService } from './files.service';
|
||||
|
||||
/**
|
||||
* Replacing a stored document must never destroy the previous one: the customer
|
||||
* uploaded it, and a staff correction has to stay auditable against it. The old
|
||||
* row is soft-deleted (so every normal read still returns exactly the current
|
||||
* version) and stamped with who replaced it and why.
|
||||
*/
|
||||
describe('FilesService — document versions', () => {
|
||||
const file = {
|
||||
originalname: 'bill-of-lading.pdf',
|
||||
size: 1234,
|
||||
mimetype: 'application/pdf',
|
||||
buffer: Buffer.from('x'),
|
||||
} as Express.Multer.File;
|
||||
|
||||
let filesRepository: {
|
||||
deleteByCode: jest.Mock;
|
||||
create: jest.Mock;
|
||||
findVersionHistory: jest.Mock;
|
||||
};
|
||||
let service: FilesService;
|
||||
|
||||
beforeEach(() => {
|
||||
filesRepository = {
|
||||
deleteByCode: jest.fn().mockResolvedValue(undefined),
|
||||
create: jest.fn(async (row) => ({ id: 'file-new', ...row })),
|
||||
findVersionHistory: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
service = new FilesService(
|
||||
filesRepository as never,
|
||||
{
|
||||
uploadFile: jest.fn().mockResolvedValue('https://minio/bucket/new.pdf'),
|
||||
getObjectNameFromUrl: (u: string) => u,
|
||||
getSignedUrl: jest.fn(),
|
||||
} as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('stamps the retired version with who replaced it and why', async () => {
|
||||
await service.upsertByCode(
|
||||
{ resourceId: 'ctr-1', resource: 'contracts', code: 'bill_of_lading', file },
|
||||
{ userId: 'gl-user-1', reason: 'Customer sent page 2 only' },
|
||||
);
|
||||
|
||||
expect(filesRepository.deleteByCode).toHaveBeenCalledWith(
|
||||
'ctr-1',
|
||||
'contracts',
|
||||
'bill_of_lading',
|
||||
{ userId: 'gl-user-1', reason: 'Customer sent page 2 only' },
|
||||
);
|
||||
});
|
||||
|
||||
it('still replaces silently when no replacer is given (system overwrites)', async () => {
|
||||
await service.upsertByCode({
|
||||
resourceId: 'ctr-1',
|
||||
resource: 'contracts',
|
||||
code: 'contract_pdf',
|
||||
file,
|
||||
});
|
||||
|
||||
expect(filesRepository.deleteByCode).toHaveBeenCalledWith(
|
||||
'ctr-1',
|
||||
'contracts',
|
||||
'contract_pdf',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('marks the live row current and the soft-deleted ones superseded', async () => {
|
||||
filesRepository.findVersionHistory.mockResolvedValue([
|
||||
{
|
||||
id: 'v2',
|
||||
name: 'corrected.pdf',
|
||||
url: 'u2',
|
||||
size: 2,
|
||||
mimeType: 'application/pdf',
|
||||
createdAt: new Date('2026-07-20T10:00:00Z'),
|
||||
deletedAt: null,
|
||||
replacedByUserId: null,
|
||||
replaceReason: null,
|
||||
},
|
||||
{
|
||||
id: 'v1',
|
||||
name: 'original.pdf',
|
||||
url: 'u1',
|
||||
size: 1,
|
||||
mimeType: 'application/pdf',
|
||||
createdAt: new Date('2026-07-18T10:00:00Z'),
|
||||
deletedAt: new Date('2026-07-20T10:00:00Z'),
|
||||
replacedByUserId: 'gl-user-1',
|
||||
replaceReason: 'Wrong page order',
|
||||
},
|
||||
]);
|
||||
|
||||
const versions = await service.versionHistory(
|
||||
'ctr-1',
|
||||
'contracts',
|
||||
'bill_of_lading',
|
||||
);
|
||||
|
||||
expect(versions[0]).toMatchObject({ id: 'v2', isCurrent: true, replacedAt: null });
|
||||
expect(versions[1]).toMatchObject({
|
||||
id: 'v1',
|
||||
isCurrent: false,
|
||||
replacedByUserId: 'gl-user-1',
|
||||
replaceReason: 'Wrong page order',
|
||||
});
|
||||
// The customer's original is still readable — that is the whole point.
|
||||
expect(versions[1].url).toBe('u1');
|
||||
});
|
||||
});
|
||||
@@ -104,13 +104,66 @@ export class FilesService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Replace existing file row for the same resource + code (e.g. contract PDF). */
|
||||
async upsertByCode(input: CreateFileInput): Promise<FileRecord> {
|
||||
/**
|
||||
* Replace the file stored under a resource + code (e.g. contract PDF). The
|
||||
* previous version is retired, not destroyed — pass `replacedBy` to record who
|
||||
* swapped it and why, which is what the version history shows.
|
||||
*/
|
||||
async upsertByCode(
|
||||
input: CreateFileInput,
|
||||
replacedBy?: { userId?: string | null; reason?: string | null },
|
||||
): Promise<FileRecord> {
|
||||
const { resourceId, resource, code } = input;
|
||||
await this.filesRepository.deleteByCode(resourceId, resource, code);
|
||||
await this.filesRepository.deleteByCode(
|
||||
resourceId,
|
||||
resource,
|
||||
code,
|
||||
replacedBy,
|
||||
);
|
||||
return this.upload(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every stored version of one document, newest first. `isCurrent` marks the
|
||||
* live row; the rest are superseded uploads kept for audit.
|
||||
*/
|
||||
async versionHistory(
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
code: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
uploadedAt: string;
|
||||
isCurrent: boolean;
|
||||
replacedAt: string | null;
|
||||
replacedByUserId: string | null;
|
||||
replaceReason: string | null;
|
||||
}>
|
||||
> {
|
||||
const rows = await this.filesRepository.findVersionHistory(
|
||||
resourceId,
|
||||
resource,
|
||||
code,
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
url: row.url,
|
||||
size: row.size,
|
||||
mimeType: row.mimeType,
|
||||
uploadedAt: row.createdAt.toISOString(),
|
||||
isCurrent: row.deletedAt == null,
|
||||
replacedAt: row.deletedAt ? row.deletedAt.toISOString() : null,
|
||||
replacedByUserId: row.replacedByUserId,
|
||||
replaceReason: row.replaceReason,
|
||||
}));
|
||||
}
|
||||
|
||||
async deleteByCode(
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
|
||||
Reference in New Issue
Block a user