diff --git a/packages/api/docs/src/models/record.yaml b/packages/api/docs/src/models/record.yaml index bbc48c75..d486ff66 100644 --- a/packages/api/docs/src/models/record.yaml +++ b/packages/api/docs/src/models/record.yaml @@ -65,6 +65,13 @@ record: thumbnailUrls: type: object properties: + width256Status: + description: This field will be null for record types where we never generate thumbnails + type: string + enum: + - ok + - processing + - failed 200: type: string 256: @@ -93,6 +100,13 @@ record: type: array items: $ref: "./archive_file.yaml#/archiveFile" + accessCopyStatus: + description: This field will be null for record types where we never generate access copies + type: string + enum: + - ok + - processing + - failed folderLinkId: type: string folderLinkType: diff --git a/packages/api/src/record/controller/get_records_page.test.ts b/packages/api/src/record/controller/get_records_page.test.ts index 24761c73..ae22be15 100644 --- a/packages/api/src/record/controller/get_records_page.test.ts +++ b/packages/api/src/record/controller/get_records_page.test.ts @@ -11,7 +11,12 @@ import { import request from "supertest"; import { app } from "../../app.js"; import { db } from "../../database.js"; -import type { ArchiveRecord, GetRecordsResponse } from "../models.js"; +import { + AccessCopyStatus, + Thumbnail256Status, + type ArchiveRecord, + type GetRecordsResponse, +} from "../models.js"; import { runFixtures } from "../../../test/run_fixtures.js"; import { mockExtractShareTokenFromHeaders, @@ -48,6 +53,21 @@ const setupDatabase = async (): Promise => { ]); }; +const setupStatusFieldDatabase = async (): Promise => { + await runFixtures(db, [ + "record.fixtures.create_test_accounts", + "record.fixtures.create_test_archives", + "record.fixtures.create_test_account_archives", + "record.fixtures.create_test_status_field_archives", + "record.fixtures.create_test_status_field_account_archives", + "record.fixtures.create_test_status_field_folders", + "record.fixtures.create_test_status_field_records", + "record.fixtures.create_test_status_field_files", + "record.fixtures.create_test_status_field_record_files", + "record.fixtures.create_test_status_field_folder_links", + ]); +}; + const clearDatabase = async (): Promise => { await db.query( `TRUNCATE @@ -380,3 +400,98 @@ describe("GET /records", () => { expect(logger.error).toHaveBeenCalledWith(testError); }); }); + +describe("GET /records accessCopyStatus and thumbnailUrls.width256Status", () => { + beforeEach(async () => { + mockExtractUserEmailFromAuthToken("test@permanent.org"); + mockExtractShareTokenFromHeaders(); + await clearDatabase(); + await setupStatusFieldDatabase(); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + }); + + afterAll(async () => { + await clearDatabase(); + }); + + const agent = request(app); + + test("expect accessCopyStatus to be ok when an archivematica access copy file exists", async () => { + const response = await agent + .get("/api/v2/records?recordIds[]=20001&pageSize=100") + .expect(200); + const { body } = response as { body: GetRecordsResponse }; + expect(body.items).toHaveLength(1); + expect(body.items[0]?.accessCopyStatus).toEqual(AccessCopyStatus.Ok); + expect(body.items[0]?.thumbnailUrls.width256Status).toBeNull(); + }); + + test("expect accessCopyStatus and thumbnailUrls.width256Status to be processing for a recent record with only an original image file", async () => { + const response = await agent + .get("/api/v2/records?recordIds[]=20002&pageSize=100") + .expect(200); + const { body } = response as { body: GetRecordsResponse }; + expect(body.items).toHaveLength(1); + expect(body.items[0]?.accessCopyStatus).toEqual( + AccessCopyStatus.Processing, + ); + expect(body.items[0]?.thumbnailUrls.width256Status).toEqual( + Thumbnail256Status.Processing, + ); + }); + + test("expect thumbnailUrls.width256Status and accessCopyStatus to be failed for an old record with only an original image file and no 256px thumbnail", async () => { + const response = await agent + .get("/api/v2/records?recordIds[]=20003&pageSize=100") + .expect(200); + const { body } = response as { body: GetRecordsResponse }; + expect(body.items).toHaveLength(1); + expect(body.items[0]?.thumbnailUrls.width256Status).toEqual( + Thumbnail256Status.Failed, + ); + expect(body.items[0]?.accessCopyStatus).toBe(AccessCopyStatus.Failed); + }); + + test("expect thumbnailUrls.width256Status to be ok and the URL fields to pass through record thumbnail columns when a 256px thumbnail already exists", async () => { + const response = await agent + .get("/api/v2/records?recordIds[]=20004&pageSize=100") + .expect(200); + const { body } = response as { body: GetRecordsResponse }; + expect(body.items).toHaveLength(1); + expect(body.items[0]?.accessCopyStatus).toBeNull(); + expect(body.items[0]?.thumbnailUrls).toEqual({ + width256Status: Thumbnail256Status.Ok, + "200": "https://localcdn.permanent.org/20004/thumb200.jpg", + "500": "https://localcdn.permanent.org/20004/thumb500.jpg", + "1000": "https://localcdn.permanent.org/20004/thumb1000.jpg", + "2000": "https://localcdn.permanent.org/20004/thumb2000.jpg", + "256": "https://localcdn.permanent.org/20004/thumb256.jpg", + }); + }); + + test("expect accessCopyStatus and thumbnailUrls.width256Status to both be null for a record whose only file is an unsupported type", async () => { + const response = await agent + .get("/api/v2/records?recordIds[]=20005&pageSize=100") + .expect(200); + const { body } = response as { body: GetRecordsResponse }; + expect(body.items).toHaveLength(1); + expect(body.items[0]?.accessCopyStatus).toBeNull(); + expect(body.items[0]?.thumbnailUrls.width256Status).toBeNull(); + }); + + test("expect accessCopyStatus to be processing for video files while thumbnailUrls.width256Status stays null, since 256px thumbnails aren't generated for video", async () => { + const response = await agent + .get("/api/v2/records?recordIds[]=20006&pageSize=100") + .expect(200); + const { body } = response as { body: GetRecordsResponse }; + expect(body.items).toHaveLength(1); + expect(body.items[0]?.accessCopyStatus).toEqual( + AccessCopyStatus.Processing, + ); + expect(body.items[0]?.thumbnailUrls.width256Status).toBeNull(); + }); +}); diff --git a/packages/api/src/record/fixtures/create_test_status_field_account_archives.sql b/packages/api/src/record/fixtures/create_test_status_field_account_archives.sql new file mode 100644 index 00000000..5fc73ecd --- /dev/null +++ b/packages/api/src/record/fixtures/create_test_status_field_account_archives.sql @@ -0,0 +1,24 @@ +INSERT INTO +account_archive ( + account_archiveid, + accountid, + archiveid, + accessrole, + position, + type, + status, + createddt, + updateddt +) +VALUES +( + 100, + 2, + 100, + 'access.role.owner', + 0, + 'type.account.standard', + 'status.generic.ok', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +); diff --git a/packages/api/src/record/fixtures/create_test_status_field_archives.sql b/packages/api/src/record/fixtures/create_test_status_field_archives.sql new file mode 100644 index 00000000..1e020cdf --- /dev/null +++ b/packages/api/src/record/fixtures/create_test_status_field_archives.sql @@ -0,0 +1,11 @@ +INSERT INTO +archive (archiveid, archivenbr, payeraccountid, public, type, status) +VALUES +( + 100, + '0100-0100', + 2, + false, + 'type.archive.person', + 'status.generic.ok' +); diff --git a/packages/api/src/record/fixtures/create_test_status_field_files.sql b/packages/api/src/record/fixtures/create_test_status_field_files.sql new file mode 100644 index 00000000..d806377d --- /dev/null +++ b/packages/api/src/record/fixtures/create_test_status_field_files.sql @@ -0,0 +1,85 @@ +INSERT INTO file ( + fileid, + archiveid, + size, + format, + type, + status, + fileurl, + downloadurl, + createddt, + updateddt +) +VALUES +( + 20001, + 100, + 1024, + 'file.format.archivematica.access', + 'type.file.image.jpeg', + 'status.generic.ok', + 'https://localcdn.permanent.org/20001/file', + 'https://localcdn.permanent.org/20001/download', + '2023-06-21T00:00:00.000Z', + '2023-06-21T00:00:00.000Z' +), +( + 20002, + 100, + 1024, + 'file.format.original', + 'type.file.image.jpeg', + 'status.generic.ok', + 'https://localcdn.permanent.org/20002/file', + 'https://localcdn.permanent.org/20002/download', + '2023-06-21T00:00:00.000Z', + '2023-06-21T00:00:00.000Z' +), +( + 20003, + 100, + 1024, + 'file.format.original', + 'type.file.image.jpeg', + 'status.generic.ok', + 'https://localcdn.permanent.org/20003/file', + 'https://localcdn.permanent.org/20003/download', + '2023-06-21T00:00:00.000Z', + '2023-06-21T00:00:00.000Z' +), +( + 20004, + 100, + 1024, + 'file.format.converted', + 'type.file.image.jpg', + 'status.generic.ok', + 'https://localcdn.permanent.org/20004/file', + 'https://localcdn.permanent.org/20004/download', + '2023-06-21T00:00:00.000Z', + '2023-06-21T00:00:00.000Z' +), +( + 20005, + 100, + 1024, + 'file.format.original', + 'type.file.archive.zip', + 'status.generic.ok', + 'https://localcdn.permanent.org/20005/file', + 'https://localcdn.permanent.org/20005/download', + '2023-06-21T00:00:00.000Z', + '2023-06-21T00:00:00.000Z' +), +( + 20006, + 100, + 1024, + 'file.format.original', + 'type.file.video.mp4', + 'status.generic.ok', + 'https://localcdn.permanent.org/20006/file', + 'https://localcdn.permanent.org/20006/download', + '2023-06-21T00:00:00.000Z', + '2023-06-21T00:00:00.000Z' +); diff --git a/packages/api/src/record/fixtures/create_test_status_field_folder_links.sql b/packages/api/src/record/fixtures/create_test_status_field_folder_links.sql new file mode 100644 index 00000000..733fd38f --- /dev/null +++ b/packages/api/src/record/fixtures/create_test_status_field_folder_links.sql @@ -0,0 +1,85 @@ +INSERT INTO folder_link ( + folder_linkid, + recordid, + folderid, + parentfolderid, + parentfolder_linkid, + archiveid, + position, + accessrole, + status, + type +) +VALUES +( + 20001, + 20001, + NULL, + 100, + NULL, + 100, + 1, + 'access.role.owner', + 'status.generic.ok', + 'type.folder_link.private' +), +( + 20002, + 20002, + NULL, + 100, + NULL, + 100, + 1, + 'access.role.owner', + 'status.generic.ok', + 'type.folder_link.private' +), +( + 20003, + 20003, + NULL, + 100, + NULL, + 100, + 1, + 'access.role.owner', + 'status.generic.ok', + 'type.folder_link.private' +), +( + 20004, + 20004, + NULL, + 100, + NULL, + 100, + 1, + 'access.role.owner', + 'status.generic.ok', + 'type.folder_link.private' +), +( + 20005, + 20005, + NULL, + 100, + NULL, + 100, + 1, + 'access.role.owner', + 'status.generic.ok', + 'type.folder_link.private' +), +( + 20006, + 20006, + NULL, + 100, + NULL, + 100, + 1, + 'access.role.owner', + 'status.generic.ok', + 'type.folder_link.private' +); diff --git a/packages/api/src/record/fixtures/create_test_status_field_folders.sql b/packages/api/src/record/fixtures/create_test_status_field_folders.sql new file mode 100644 index 00000000..ec42c474 --- /dev/null +++ b/packages/api/src/record/fixtures/create_test_status_field_folders.sql @@ -0,0 +1,22 @@ +INSERT INTO +folder ( + folderid, + archiveid, + publicdt, + displayname, + downloadname, + status, + createddt, + type +) +VALUES +( + 100, + 100, + NULL, + 'Status Field Test Root', + 'Status Field Test Root', + 'status.generic.ok', + CURRENT_TIMESTAMP, + 'type.folder.root.private' +); diff --git a/packages/api/src/record/fixtures/create_test_status_field_record_files.sql b/packages/api/src/record/fixtures/create_test_status_field_record_files.sql new file mode 100644 index 00000000..6a178cf5 --- /dev/null +++ b/packages/api/src/record/fixtures/create_test_status_field_record_files.sql @@ -0,0 +1,64 @@ +INSERT INTO record_file ( + record_fileid, + recordid, + fileid, + status, + type, + createddt, + updateddt +) +VALUES +( + 20001, + 20001, + 20001, + 'status.generic.ok', + 'type.generic.placeholder', + '2023-06-21T00:00:00.000Z', + '2023-06-21T00:00:00.000Z' +), +( + 20002, + 20002, + 20002, + 'status.generic.ok', + 'type.generic.placeholder', + '2023-06-21T00:00:00.000Z', + '2023-06-21T00:00:00.000Z' +), +( + 20003, + 20003, + 20003, + 'status.generic.ok', + 'type.generic.placeholder', + '2023-06-21T00:00:00.000Z', + '2023-06-21T00:00:00.000Z' +), +( + 20004, + 20004, + 20004, + 'status.generic.ok', + 'type.generic.placeholder', + '2023-06-21T00:00:00.000Z', + '2023-06-21T00:00:00.000Z' +), +( + 20005, + 20005, + 20005, + 'status.generic.ok', + 'type.generic.placeholder', + '2023-06-21T00:00:00.000Z', + '2023-06-21T00:00:00.000Z' +), +( + 20006, + 20006, + 20006, + 'status.generic.ok', + 'type.generic.placeholder', + '2023-06-21T00:00:00.000Z', + '2023-06-21T00:00:00.000Z' +); diff --git a/packages/api/src/record/fixtures/create_test_status_field_records.sql b/packages/api/src/record/fixtures/create_test_status_field_records.sql new file mode 100644 index 00000000..e118ec98 --- /dev/null +++ b/packages/api/src/record/fixtures/create_test_status_field_records.sql @@ -0,0 +1,135 @@ +INSERT INTO +record ( + recordid, + archiveid, + archivenbr, + publicdt, + displayname, + uploadaccountid, + uploadpayeraccountid, + uploadfilename, + downloadname, + status, + type, + createddt, + thumbnail256, + thumburl200, + thumburl500, + thumburl1000, + thumburl2000 +) +VALUES +( + 20001, + 100, + NULL, + NULL, + 'Access Copy Ready', + 2, + 2, + 'access_copy_ready.jpg', + 'access_copy_ready.jpg', + 'status.generic.ok', + 'type.record.image', + '2023-06-21T00:00:00.000Z', + NULL, + NULL, + NULL, + NULL, + NULL +), +( + 20002, + 100, + NULL, + NULL, + 'Access Copy And Thumbnail Processing', + 2, + 2, + 'processing.jpg', + 'processing.jpg', + 'status.generic.ok', + 'type.record.image', + CURRENT_TIMESTAMP, + NULL, + NULL, + NULL, + NULL, + NULL +), +( + 20003, + 100, + NULL, + NULL, + 'Thumbnail Failed', + 2, + 2, + 'failed.jpg', + 'failed.jpg', + 'status.generic.ok', + 'type.record.image', + '2023-01-01T00:00:00.000Z', + NULL, + NULL, + NULL, + NULL, + NULL +), +( + 20004, + 100, + NULL, + NULL, + 'Thumbnail Already Generated', + 2, + 2, + 'already_generated.jpg', + 'already_generated.jpg', + 'status.generic.ok', + 'type.record.image', + '2023-06-21T00:00:00.000Z', + 'https://localcdn.permanent.org/20004/thumb256.jpg', + 'https://localcdn.permanent.org/20004/thumb200.jpg', + 'https://localcdn.permanent.org/20004/thumb500.jpg', + 'https://localcdn.permanent.org/20004/thumb1000.jpg', + 'https://localcdn.permanent.org/20004/thumb2000.jpg' +), +( + 20005, + 100, + NULL, + NULL, + 'Unsupported File Type', + 2, + 2, + 'archive.zip', + 'archive.zip', + 'status.generic.ok', + 'type.record.archive', + CURRENT_TIMESTAMP, + NULL, + NULL, + NULL, + NULL, + NULL +), +( + 20006, + 100, + NULL, + NULL, + 'Video Access Copy Processing', + 2, + 2, + 'video.mp4', + 'video.mp4', + 'status.generic.ok', + 'type.record.video', + CURRENT_TIMESTAMP, + NULL, + NULL, + NULL, + NULL, + NULL +); diff --git a/packages/api/src/record/models.ts b/packages/api/src/record/models.ts index 42576fdc..a3bbe2c6 100644 --- a/packages/api/src/record/models.ts +++ b/packages/api/src/record/models.ts @@ -54,6 +54,8 @@ export interface ArchiveRecord { }; }; accessRole: ArchiveMembershipRole; + accessCopyStatus: AccessCopyStatus | null; + thumbnailUrls: RecordThumbnailUrls; } export interface ArchiveRecordRow { @@ -107,6 +109,29 @@ export interface ArchiveRecordRow { archiveAccessRole: AccessRole | null; shareAccessRoles: ShareAccessRolePair[] | null; shareTokenGrantsAccess: boolean; + accessCopyStatus: AccessCopyStatus | null; + thumbnailUrls: RecordThumbnailUrls; +} + +export enum AccessCopyStatus { + Ok = "ok", + Processing = "processing", + Failed = "failed", +} + +export enum Thumbnail256Status { + Ok = "ok", + Processing = "processing", + Failed = "failed", +} + +export interface RecordThumbnailUrls { + width256Status: Thumbnail256Status | null; + "200": string | null; + "500": string | null; + "1000": string | null; + "2000": string | null; + "256": string | null; } export interface ArchiveFile { @@ -177,6 +202,7 @@ export enum RecordType { export enum FileFormat { Original = "file.format.original", Converted = "file.format.converted", + ArchivematicaAccess = "file.format.archivematica.access", } enum FolderLinkType { diff --git a/packages/api/src/record/queries/get_records.sql b/packages/api/src/record/queries/get_records.sql index 155307a5..b72a425f 100644 --- a/packages/api/src/record/queries/get_records.sql +++ b/packages/api/src/record/queries/get_records.sql @@ -215,6 +215,108 @@ all_records AS ( archive.archivenbr AS "archiveArchiveNumber", aggregated_shares.shares_as_json AS shares, COALESCE(record.publicdt <= NOW(), FALSE) AS "isPublic", + CASE + WHEN + EXISTS ( + SELECT 1 FROM UNNEST(aggregated_files.files) AS files + WHERE + files ->> 'format' = 'file.format.archivematica.access' + AND files ->> 'type' != 'type.file.unknown.null' + ) + THEN 'ok' + WHEN + EXISTS ( + SELECT 1 FROM UNNEST(aggregated_files.files) AS files + WHERE + files ->> 'format' = 'file.format.original' + AND files ->> 'type' IN ( + 'type.file.image.bmp', + 'type.file.image.gif', + 'type.file.image.jpeg', + 'type.file.image.png', + 'type.file.image.tiff', + 'type.file.image.tif', + 'type.file.image.jpg', + 'type.file.image.heic', + 'type.file.video.avi', + 'type.file.video.mov', + 'type.file.video.ogv', + 'type.file.video.webm', + 'type.file.video.mp4', + 'type.file.document.doc', + 'type.file.document.docx', + 'type.file.document.rtf', + 'type.file.document.eml', + 'type.file.document.odt', + 'type.file.document.txt', + 'type.file.pdf.pdf', + 'type.file.pdf.pdfa', + 'type.file.presentation.ppt', + 'type.file.presentation.pptx', + 'type.file.presentation.key', + 'type.file.presentation.odp', + 'type.file.spreadsheet.xls', + 'type.file.spreadsheet.xlsx', + 'type.file.spreadsheet.ods', + 'type.file.audio.aac', + 'type.file.audio.aiff', + 'type.file.audio.flac', + 'type.file.audio.m4a', + 'type.file.audio.ogg', + 'type.file.audio.wav', + 'type.file.audio.wma', + 'type.file.audio.mp3' + ) + ) + AND record.createddt > CURRENT_TIMESTAMP - '1 day'::INTERVAL + THEN 'processing' + WHEN + EXISTS ( + SELECT 1 FROM UNNEST(aggregated_files.files) AS files + WHERE + files ->> 'format' = 'file.format.original' + AND files ->> 'type' IN ( + 'type.file.image.bmp', + 'type.file.image.gif', + 'type.file.image.jpeg', + 'type.file.image.png', + 'type.file.image.tiff', + 'type.file.image.tif', + 'type.file.image.jpg', + 'type.file.image.heic', + 'type.file.video.avi', + 'type.file.video.mov', + 'type.file.video.ogv', + 'type.file.video.webm', + 'type.file.video.mp4', + 'type.file.document.doc', + 'type.file.document.docx', + 'type.file.document.rtf', + 'type.file.document.eml', + 'type.file.document.odt', + 'type.file.document.txt', + 'type.file.pdf.pdf', + 'type.file.pdf.pdfa', + 'type.file.presentation.ppt', + 'type.file.presentation.pptx', + 'type.file.presentation.key', + 'type.file.presentation.odp', + 'type.file.spreadsheet.xls', + 'type.file.spreadsheet.xlsx', + 'type.file.spreadsheet.ods', + 'type.file.audio.aac', + 'type.file.audio.aiff', + 'type.file.audio.flac', + 'type.file.audio.m4a', + 'type.file.audio.ogg', + 'type.file.audio.wav', + 'type.file.audio.wma', + 'type.file.audio.mp3' + ) + ) + AND record.createddt <= CURRENT_TIMESTAMP - '1 day'::INTERVAL + THEN 'failed' + END AS "accessCopyStatus", CASE WHEN EXISTS ( @@ -234,6 +336,74 @@ all_records AS ( THEN aggregated_pending_shares.pending_shares_as_json END AS "pendingShares", JSON_BUILD_OBJECT( + 'width256Status', + CASE + WHEN record.thumbnail256 IS NOT NULL THEN 'ok' + WHEN + EXISTS ( + SELECT 1 FROM UNNEST(aggregated_files.files) AS files + WHERE + files ->> 'format' = 'file.format.original' + AND files ->> 'type' IN ( + 'type.file.image.bmp', + 'type.file.image.gif', + 'type.file.image.jpeg', + 'type.file.image.png', + 'type.file.image.tiff', + 'type.file.image.tif', + 'type.file.image.jpg', + 'type.file.image.heic', + 'type.file.document.doc', + 'type.file.document.docx', + 'type.file.document.rtf', + 'type.file.document.eml', + 'type.file.document.odt', + 'type.file.pdf.pdf', + 'type.file.pdf.pdfa', + 'type.file.presentation.ppt', + 'type.file.presentation.pptx', + 'type.file.presentation.key', + 'type.file.presentation.odp', + 'type.file.spreadsheet.xls', + 'type.file.spreadsheet.xlsx', + 'type.file.spreadsheet.ods' + ) + ) + AND record.createddt > CURRENT_TIMESTAMP - '1 day'::INTERVAL + THEN 'processing' + WHEN + EXISTS ( + SELECT 1 FROM UNNEST(aggregated_files.files) AS files + WHERE + files ->> 'format' = 'file.format.original' + AND files ->> 'type' IN ( + 'type.file.image.bmp', + 'type.file.image.gif', + 'type.file.image.jpeg', + 'type.file.image.png', + 'type.file.image.tiff', + 'type.file.image.tif', + 'type.file.image.jpg', + 'type.file.image.heic', + 'type.file.document.doc', + 'type.file.document.docx', + 'type.file.document.rtf', + 'type.file.document.eml', + 'type.file.document.odt', + 'type.file.pdf.pdf', + 'type.file.pdf.pdfa', + 'type.file.presentation.ppt', + 'type.file.presentation.pptx', + 'type.file.presentation.key', + 'type.file.presentation.odp', + 'type.file.spreadsheet.xls', + 'type.file.spreadsheet.xlsx', + 'type.file.spreadsheet.ods' + ) + ) + AND record.createddt <= CURRENT_TIMESTAMP - '1 day'::INTERVAL + THEN 'failed' + END, '200', record.thumburl200, '500',