add dispute functionality for contract duty and implement collection dates

This commit is contained in:
Marshal
2026-07-26 16:58:51 +00:00
parent 9b13fa2ac6
commit 5e10c97294
74 changed files with 3684 additions and 342 deletions

View File

@@ -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;
}

View File

@@ -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" },
});
}
/**

View 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');
});
});

View File

@@ -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,