Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apps/api/src/isms/isms-document-control.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,20 @@ describe('IsmsDocumentControlService', () => {
skipDuplicates: true,
});
});

it('takes the per-document lock BEFORE writing links (serializes vs approve)', async () => {
await service.addControls({
documentId: 'doc_1',
organizationId: 'org_1',
controlIds: ['ctl_1', 'ctl_2'],
});

const lockOrder = (mockDb.$executeRaw as jest.Mock).mock
.invocationCallOrder[0];
const writeOrder = (mockDb.ismsDocumentControlLink.createMany as jest.Mock)
.mock.invocationCallOrder[0];
expect(lockOrder).toBeLessThan(writeOrder);
});
});

describe('removeControl', () => {
Expand Down
9 changes: 9 additions & 0 deletions apps/api/src/isms/isms-document-control.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
} from '@nestjs/common';
import { db } from '@db';
import { invalidateApprovalIfNeeded } from './utils/approval';
import { lockDocument } from './utils/document-lock';

/**
* Org-level mapping between an ISMS document and the organization's Controls
Expand Down Expand Up @@ -41,6 +42,11 @@ export class IsmsDocumentControlService {
// register/narrative edits). Only a REAL change invalidates — an idempotent
// re-link that inserts nothing must not downgrade an approved document.
await db.$transaction(async (tx) => {
// Take the per-document lock BEFORE writing links so the mutation is fully
// serialized against approve() (which holds the same lock). invalidate is
// only called on a real change, and the lock is re-entrant, so re-taking it
// there is a no-op.
await lockDocument(tx, documentId);
const { count } = await tx.ismsDocumentControlLink.createMany({
data: uniqueControlIds.map((controlId) => ({
ismsDocumentId: documentId,
Expand Down Expand Up @@ -68,6 +74,9 @@ export class IsmsDocumentControlService {
// Only a real unlink (a row actually deleted) invalidates sign-off; removing
// a control that wasn't linked must not downgrade an approved document.
await db.$transaction(async (tx) => {
// Lock before the delete so the mutation serializes against approve()
// (same lock); invalidate only on a real unlink, re-taking the lock no-ops.
await lockDocument(tx, documentId);
const { count } = await tx.ismsDocumentControlLink.deleteMany({
where: { ismsDocumentId: documentId, controlId },
});
Expand Down
Loading