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
34 changes: 34 additions & 0 deletions apps/backend-services/src/document/document-db.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,40 @@ describe("DocumentDbService", () => {
});
});

it("should expand the 'failed' status filter to include conversion_failed", async () => {
const docs = [{ ...makeDocument(), workflowVersion: null }];
mockPrismaDocument.findMany.mockResolvedValue(docs);
mockPrismaDocument.count.mockResolvedValue(1);

await service.findAllDocuments(undefined, { status: "failed" });

const expectedWhere = {
status: { in: ["failed", "conversion_failed"] },
};
expect(mockPrismaDocument.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: expectedWhere }),
);
expect(mockPrismaDocument.count).toHaveBeenCalledWith({
where: expectedWhere,
});
});

it("should match a non-failed status filter exactly", async () => {
const docs = [{ ...makeDocument(), workflowVersion: null }];
mockPrismaDocument.findMany.mockResolvedValue(docs);
mockPrismaDocument.count.mockResolvedValue(1);

await service.findAllDocuments(undefined, {
status: DocumentStatus.complete,
});

expect(mockPrismaDocument.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { status: DocumentStatus.complete },
}),
);
});

it("should throw if prisma throws", async () => {
mockPrismaDocument.findMany.mockRejectedValue(new Error("DB error"));
mockPrismaDocument.count.mockResolvedValue(0);
Expand Down
7 changes: 6 additions & 1 deletion apps/backend-services/src/document/document-db.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,13 @@ export class DocumentDbService {
}

// Status filter
// The "failed" filter covers both failure states surfaced under a single
// "Failed" bucket in the UI: extraction failures and PDF conversion failures.
if (options?.status && options.status !== "all") {
where.status = options.status;
where.status =
options.status === "failed"
? { in: ["failed", "conversion_failed"] }
: options.status;
}

// Source filter
Expand Down
106 changes: 106 additions & 0 deletions apps/backend-services/src/document/document.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { GroupRole } from "@generated/client";
import {
BadRequestException,
ForbiddenException,
GoneException,
NotFoundException,
} from "@nestjs/common";
import { mockAppLogger } from "@/testUtils/mockAppLogger";
Expand Down Expand Up @@ -540,6 +541,111 @@ describe("DocumentController", () => {
controller.downloadDocument("1", res, mockReq as any),
).rejects.toThrow(NotFoundException);
});

it("should throw GoneException when the document has been purged", async () => {
documentService.findDocument.mockResolvedValue({
id: "1",
file_path: "cuid/ocr/file.pdf",
original_filename: "file.pdf",
group_id: mockGroupId,
purged_at: new Date(),
} as any);
const res: any = {};
await expect(
controller.downloadDocument("1", res, mockReq as any),
).rejects.toThrow(GoneException);
expect(blobStorage.read).not.toHaveBeenCalled();
});
});

describe("viewDocument", () => {
const mockReq = createMockReq();

it("should stream the normalized PDF when present", async () => {
documentService.findDocument.mockResolvedValue({
id: "1",
normalized_file_path: "cuid/ocr/normalized.pdf",
group_id: mockGroupId,
purged_at: null,
} as any);
blobStorage.read.mockResolvedValue(Buffer.from("pdf"));
const res: any = { setHeader: jest.fn(), send: jest.fn() };
await controller.viewDocument("1", res, mockReq as any);
expect(res.setHeader).toHaveBeenCalledWith(
"Content-Type",
"application/pdf",
);
expect(res.send).toHaveBeenCalledWith(Buffer.from("pdf"));
});

it("should throw GoneException when the document has been purged", async () => {
documentService.findDocument.mockResolvedValue({
id: "1",
normalized_file_path: "cuid/ocr/normalized.pdf",
group_id: mockGroupId,
purged_at: new Date(),
} as any);
const res: any = { setHeader: jest.fn(), send: jest.fn() };
await expect(
controller.viewDocument("1", res, mockReq as any),
).rejects.toThrow(GoneException);
expect(blobStorage.read).not.toHaveBeenCalled();
expect(mockAuditService.recordEvent).not.toHaveBeenCalled();
});

it("should throw NotFoundException when normalized PDF path is missing", async () => {
documentService.findDocument.mockResolvedValue({
id: "1",
normalized_file_path: null,
group_id: mockGroupId,
purged_at: null,
} as any);
const res: any = { setHeader: jest.fn(), send: jest.fn() };
await expect(
controller.viewDocument("1", res, mockReq as any),
).rejects.toThrow(NotFoundException);
});

it("should throw NotFoundException (not 500) when the blob read fails", async () => {
documentService.findDocument.mockResolvedValue({
id: "1",
normalized_file_path: "cuid/ocr/normalized.pdf",
group_id: mockGroupId,
purged_at: null,
} as any);
blobStorage.read.mockRejectedValue(new Error("NoSuchKey"));
const res: any = { setHeader: jest.fn(), send: jest.fn() };
await expect(
controller.viewDocument("1", res, mockReq as any),
).rejects.toThrow(NotFoundException);
});

it("should throw ForbiddenException if user is not a group member", async () => {
const notMemberReq = {
resolvedIdentity: {
userId: "user-1",
isSystemAdmin: false,
groupRoles: {},
},
};
documentService.findDocument.mockResolvedValue({
id: "1",
normalized_file_path: "cuid/ocr/normalized.pdf",
group_id: mockGroupId,
} as any);
const res: any = { setHeader: jest.fn(), send: jest.fn() };
await expect(
controller.viewDocument("1", res, notMemberReq as any),
).rejects.toThrow(ForbiddenException);
});

it("should throw NotFoundException if document not found", async () => {
documentService.findDocument.mockResolvedValue(null);
const res: any = { setHeader: jest.fn(), send: jest.fn() };
await expect(
controller.viewDocument("1", res, mockReq as any),
).rejects.toThrow(NotFoundException);
});
});

describe("getDocument", () => {
Expand Down
41 changes: 37 additions & 4 deletions apps/backend-services/src/document/document.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
Delete,
ForbiddenException,
Get,
GoneException,
HttpCode,
HttpStatus,
Inject,
Expand All @@ -23,6 +24,7 @@ import {
ApiBody,
ApiConflictResponse,
ApiForbiddenResponse,
ApiGoneResponse,
ApiNoContentResponse,
ApiNotFoundResponse,
ApiOkResponse,
Expand Down Expand Up @@ -561,6 +563,9 @@ export class DocumentController {
@ApiNotFoundResponse({
description: "Document not found or normalized PDF unavailable",
})
@ApiGoneResponse({
description: "Document was purged per its retention policy; original gone",
})
@ApiUnauthorizedResponse({ description: "Not authenticated" })
@ApiForbiddenResponse({ description: "Access denied: not a group member" })
async viewDocument(
Expand All @@ -578,6 +583,13 @@ export class DocumentController {

identityCanAccessGroup(req.resolvedIdentity, document.group_id);

if (document.purged_at) {
throw new GoneException(
`Document ${documentId} was purged per its retention policy; ` +
`the original is no longer available. Extracted data is retained.`,
);
}

if (!document.normalized_file_path) {
throw new NotFoundException(
`Normalized PDF is not available for document: ${documentId}`,
Expand All @@ -594,9 +606,19 @@ export class DocumentController {
payload: { action: "view" },
});

const fileBuffer = await this.blobStorage.read(
validateBlobFilePath(document.normalized_file_path),
);
let fileBuffer: Buffer;
try {
fileBuffer = await this.blobStorage.read(
validateBlobFilePath(document.normalized_file_path),
);
} catch (error) {
this.logger.error(
`Failed to read normalized PDF for document ${documentId}: ${getErrorMessage(error)}`,
);
throw new NotFoundException(
`Normalized PDF is not available for document: ${documentId}`,
);
}

res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", 'inline; filename="document.pdf"');
Expand Down Expand Up @@ -671,6 +693,9 @@ export class DocumentController {
description: "Returns the original file buffer",
})
@ApiNotFoundResponse({ description: "Document not found or file missing" })
@ApiGoneResponse({
description: "Document was purged per its retention policy; original gone",
})
@ApiUnauthorizedResponse({ description: "Not authenticated" })
@ApiForbiddenResponse({ description: "Access denied: not a group member" })
async downloadDocument(
Expand All @@ -690,6 +715,13 @@ export class DocumentController {

identityCanAccessGroup(req.resolvedIdentity, document.group_id);

if (document.purged_at) {
throw new GoneException(
`Document ${documentId} was purged per its retention policy; ` +
`the original is no longer available. Extracted data is retained.`,
);
}

// Read file from blob storage using the blob key
const filePath = validateBlobFilePath(document.file_path);
const fileBuffer = await this.blobStorage.read(filePath);
Expand Down Expand Up @@ -727,7 +759,8 @@ export class DocumentController {

if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
error instanceof ForbiddenException ||
error instanceof GoneException
) {
throw error;
}
Expand Down
13 changes: 13 additions & 0 deletions apps/backend-services/src/document/dto/document-data.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,19 @@ export class DocumentDataDto {
@ApiProperty()
updated_at!: Date;

@ApiProperty({
required: false,
nullable: true,
type: "string",
format: "date-time",
description:
"Set once the ephemeral-cleanup janitor has purged the document's " +
"blobs (original and/or normalized PDF) per its workflow's retention " +
"policy. When set, the OCR result is retained but /view and /download " +
"return 410 Gone. Null = not purged.",
})
purged_at?: Date | null;

@ApiProperty({ required: false, nullable: true, type: "string" })
apim_request_id?: string | null;

Expand Down
4 changes: 4 additions & 0 deletions apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,12 @@
"react": "19.2.1",
"react-dom": "19.2.1",
"react-konva": "19.2.1",
"react-markdown": "10.1.0",
"react-router-dom": "7.17.0",
"recharts": "3.7.0",
"rehype-raw": "7.0.0",
"rehype-sanitize": "6.0.0",
"remark-gfm": "4.0.1",
"zod": "3.25.76"
},
"devDependencies": {
Expand Down
Loading
Loading