diff --git a/API.md b/API.md index f612f3a1..8e1b79f0 100644 --- a/API.md +++ b/API.md @@ -247,3 +247,24 @@ instead return a [Joi ValidationError object](https://joi.dev/api/?v=17.9.1#vali token: string } ``` + +## Archives + +### GET `/archive/:archiveId/tags/public` + +- Response +``` +[ + { + tagId: string, + name: string, + archiveId: string, + status: string, + type: string, + createdDt: string (date), + updatedDt: string (date) + } +] +``` + +- Note: If the archive doesn't exist, this will return an empty array diff --git a/src/account/validators.test.ts b/src/account/validators.test.ts index c2a3b40d..3579d787 100644 --- a/src/account/validators.test.ts +++ b/src/account/validators.test.ts @@ -1,7 +1,7 @@ import { validateUpdateTagsRequest } from "./validators"; describe("validateUpdateTagsRequest", () => { - test("should find not errors in a valid request", () => { + test("should find no errors in a valid request", () => { let error = null; try { validateUpdateTagsRequest({ diff --git a/src/archive/controller.ts b/src/archive/controller.ts new file mode 100644 index 00000000..6167e573 --- /dev/null +++ b/src/archive/controller.ts @@ -0,0 +1,23 @@ +import { Router } from "express"; +import type { Request, Response, NextFunction } from "express"; +import { validateGetPublicTagsParams } from "./validators"; +import { isValidationError } from "../validators/validator_util"; +import { archiveService } from "./service"; + +export const archiveController = Router(); +archiveController.get( + "/:archiveId/tags/public", + async (req: Request, res: Response, next: NextFunction) => { + try { + validateGetPublicTagsParams(req.params); + const tags = await archiveService.getPublicTags(req.params.archiveId); + res.json(tags); + } catch (err) { + if (isValidationError(err)) { + res.status(400).json({ error: err }); + return; + } + next(err); + } + } +); diff --git a/src/archive/index.ts b/src/archive/index.ts new file mode 100644 index 00000000..0afbfdac --- /dev/null +++ b/src/archive/index.ts @@ -0,0 +1 @@ +export { archiveController } from "./controller"; diff --git a/src/archive/models.ts b/src/archive/models.ts new file mode 100644 index 00000000..fdfef35d --- /dev/null +++ b/src/archive/models.ts @@ -0,0 +1,9 @@ +export interface Tag { + tagId: string; + name: string; + archiveId: string; + status: string; + type: string; + createdDt: Date; + updatedDt: Date; +} diff --git a/src/archive/queries/get_public_tags.sql b/src/archive/queries/get_public_tags.sql new file mode 100644 index 00000000..7841d05e --- /dev/null +++ b/src/archive/queries/get_public_tags.sql @@ -0,0 +1,29 @@ +SELECT DISTINCT + tag.tagId "tagId", + tag.name, + tag.archiveId "archiveId", + tag.status, + tag.type, + tag.createdDt "createdDt", + tag.updatedDt "updatedDt" +FROM + tag +JOIN + tag_link + ON tag.tagId = tag_link.tagId +LEFT JOIN + record + ON tag_link.refId = record.recordId AND tag_link.refTable = 'record' +LEFT JOIN + folder + ON tag_link.refId = folder.folderId AND tag_link.refTable = 'folder' +WHERE + tag.archiveId = :archiveId + AND ( + (record.publicDt IS NOT NULL AND record.publicDt <= CURRENT_TIMESTAMP AND record.archiveId = :archiveId) + OR (folder.publicDt IS NOT NULL AND folder.publicDt <= CURRENT_TIMESTAMP AND folder.archiveId = :archiveId) + ) + AND tag.status = 'status.generic.ok' + AND tag_link.status = 'status.generic.ok' + AND record.status IS DISTINCT FROM 'status.generic.deleted' + AND folder.status IS DISTINCT FROM 'status.generic.deleted'; diff --git a/src/archive/service.test.ts b/src/archive/service.test.ts new file mode 100644 index 00000000..b48f2f84 --- /dev/null +++ b/src/archive/service.test.ts @@ -0,0 +1,63 @@ +import { InternalServerError } from "http-errors"; +import { db } from "../database"; +import { archiveService } from "./service"; +import { logger } from "../log"; + +jest.mock("../database"); +jest.mock("../log", () => ({ + logger: { + error: jest.fn(), + }, +})); + +const loadFixtures = async (): Promise => { + await db.sql("fixtures.create_test_accounts"); + await db.sql("fixtures.create_test_archive"); + await db.sql("fixtures.create_test_records"); + await db.sql("fixtures.create_test_folders"); + await db.sql("fixtures.create_test_tags"); + await db.sql("fixtures.create_test_tag_links"); +}; + +const clearDatabase = async (): Promise => { + await db.query( + "TRUNCATE account, archive, record, folder, tag, tag_link CASCADE" + ); +}; + +describe("getPublicTags", () => { + beforeEach(async () => { + await clearDatabase(); + await loadFixtures(); + }); + afterEach(async () => { + await clearDatabase(); + jest.clearAllMocks(); + }); + + test("should return public tags and not private or deleted tags", async () => { + const tags = await archiveService.getPublicTags("1"); + expect(tags.length).toEqual(2); + expect(tags.map((tag) => tag.name)).toContain("test_public_file"); + expect(tags.map((tag) => tag.name)).toContain("test_public_folder"); + }); + + test("should return empty list for nonexistent archive", async () => { + const tags = await archiveService.getPublicTags("1000"); + expect(tags.length).toEqual(0); + }); + + test("should throw an internal server error if database query fails unexpectedly", async () => { + let error = null; + const testError = new Error("out of cheese - redo from start"); + try { + jest.spyOn(db, "sql").mockRejectedValueOnce(testError); + await archiveService.getPublicTags("1"); + } catch (err) { + error = err; + } finally { + expect(error instanceof InternalServerError).toEqual(true); + expect(logger.error).toHaveBeenCalledWith(testError); + } + }); +}); diff --git a/src/archive/service.ts b/src/archive/service.ts new file mode 100644 index 00000000..87efb7ce --- /dev/null +++ b/src/archive/service.ts @@ -0,0 +1,18 @@ +import createError from "http-errors"; +import { db } from "../database"; +import { logger } from "../log"; +import type { Tag } from "./models"; + +const getPublicTags = async (archiveId: string): Promise => { + const tagsResult = await db + .sql("archive.queries.get_public_tags", { archiveId }) + .catch((err) => { + logger.error(err); + throw new createError.InternalServerError("failed to retrieve tags"); + }); + return tagsResult.rows; +}; + +export const archiveService = { + getPublicTags, +}; diff --git a/src/archive/validators.test.ts b/src/archive/validators.test.ts new file mode 100644 index 00000000..0dea7610 --- /dev/null +++ b/src/archive/validators.test.ts @@ -0,0 +1,40 @@ +import { validateGetPublicTagsParams } from "./validators"; + +describe("validateGetPublicTagsParams", () => { + test("should find no errors in valid parameters", () => { + let error = null; + try { + validateGetPublicTagsParams({ + archiveId: "123", + }); + } catch (err) { + error = err; + } finally { + expect(error).toBeNull(); + } + }); + + test("should error if archiveId is missing", () => { + let error = null; + try { + validateGetPublicTagsParams({}); + } catch (err) { + error = err; + } finally { + expect(error).not.toBeNull(); + } + }); + + test("should error if archiveId is wrong type", () => { + let error = null; + try { + validateGetPublicTagsParams({ + archiveId: 123, + }); + } catch (err) { + error = err; + } finally { + expect(error).not.toBeNull(); + } + }); +}); diff --git a/src/archive/validators.ts b/src/archive/validators.ts new file mode 100644 index 00000000..cb15f8c9 --- /dev/null +++ b/src/archive/validators.ts @@ -0,0 +1,12 @@ +import Joi from "joi"; + +export function validateGetPublicTagsParams( + data: unknown +): asserts data is { archiveId: string } { + const validation = Joi.object() + .keys({ archiveId: Joi.string().required() }) + .validate(data); + if (validation.error) { + throw validation.error; + } +} diff --git a/src/fixtures/create_test_archive.sql b/src/fixtures/create_test_archive.sql index cf6b5970..743155ff 100644 --- a/src/fixtures/create_test_archive.sql +++ b/src/fixtures/create_test_archive.sql @@ -1,4 +1,5 @@ INSERT INTO archive (archiveId, archiveNbr) VALUES - (1, '0001-0001'); + (1, '0001-0001'), + (2, '0001-0002'); diff --git a/src/fixtures/create_test_folders.sql b/src/fixtures/create_test_folders.sql new file mode 100644 index 00000000..8214eaa1 --- /dev/null +++ b/src/fixtures/create_test_folders.sql @@ -0,0 +1,8 @@ +INSERT INTO + folder (folderId, archiveId, publicDt, displayName, status) +VALUES + (1, 1, '2023-06-21', 'Public Folder', 'status.generic.ok'), + (2, 1, NULL, 'Private Folder', 'status.generic.ok'), + (3, 1, CURRENT_TIMESTAMP + '1 day'::interval, 'Future Public Folder', 'status.generic.ok'), + (4, 1, '2023-06-21', 'Deleted Folder', 'status.generic.deleted'), + (5, 2, '2023-06-21', 'Public Folder', 'status.generic.ok'); diff --git a/src/fixtures/create_test_records.sql b/src/fixtures/create_test_records.sql new file mode 100644 index 00000000..bb0eca60 --- /dev/null +++ b/src/fixtures/create_test_records.sql @@ -0,0 +1,8 @@ +INSERT INTO + record (recordId, archiveId, publicDt, displayName, uploadAccountId, uploadFilename, status, type) +VALUES + (1, 1, '2023-06-21', 'Public File', 2, 'public_file.jpg', 'status.generic.ok', 'type.record.image'), + (2, 1, NULL, 'Private File', 2, 'private_file.jpg', 'status.generic.ok', 'type.record.image'), + (3, 1, CURRENT_TIMESTAMP + '1 day'::interval, 'Future Public File', 2, 'future_public_file.jpg', 'status.generic.ok', 'type.record.image'), + (4, 1, '2023-06-21', 'Deleted File', 2, 'public_file.jpg', 'status.generic.deleted', 'type.record.image'), + (5, 2, '2023-06-21', 'Public File', 3, 'public_file.jpg', 'status.generic.ok', 'type.record.image'); diff --git a/src/fixtures/create_test_tag_links.sql b/src/fixtures/create_test_tag_links.sql new file mode 100644 index 00000000..b1c4ca5a --- /dev/null +++ b/src/fixtures/create_test_tag_links.sql @@ -0,0 +1,15 @@ +INSERT INTO + tag_link (tagId, refId, refTable, status, type, createdDt, updatedDt) +VALUES + (1, 1, 'record', 'status.generic.ok', 'type.generic.placeholder', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (2, 2, 'record', 'status.generic.ok', 'type.generic.placeholder', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (3, 3, 'record', 'status.generic.ok', 'type.generic.placeholder', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (4, 4, 'record', 'status.generic.ok', 'type.generic.placeholder', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (5, 1, 'record', 'status.generic.ok', 'type.generic.placeholder', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (6, 1, 'record', 'status.generic.deleted', 'type.generic.placeholder', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (7, 5, 'record', 'status.generic.ok', 'type.generic.placeholder', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (8, 1, 'folder', 'status.generic.ok', 'type.generic.placeholder', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (9, 2, 'folder', 'status.generic.ok', 'type.generic.placeholder', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (10, 3, 'folder', 'status.generic.ok', 'type.generic.placeholder', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (11, 4, 'folder', 'status.generic.ok', 'type.generic.placeholder', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (12, 5, 'folder', 'status.generic.ok', 'type.generic.placeholder', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); diff --git a/src/fixtures/create_test_tags.sql b/src/fixtures/create_test_tags.sql new file mode 100644 index 00000000..6086e428 --- /dev/null +++ b/src/fixtures/create_test_tags.sql @@ -0,0 +1,16 @@ +INSERT INTO + tag (tagId, name, archiveId, status, type, createdDt, updatedDt) +VALUES + (1, 'test_public_file', 1, 'status.generic.ok', 'type.generic.placeholder', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (2, 'test_private_file', 1, 'status.generic.ok', 'type.generic.placeholder', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (3, 'test_future_public_file', 1, 'status.generic.ok', 'type.generic.placeholder', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (4, 'test_deleted_file', 1, 'status.generic.ok', 'type.generic.placeholder', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (5, 'test_deleted_tag', 1, 'status.generic.deleted', 'type.generic.placeholder', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (6, 'test_deleted_link', 1, 'status.generic.ok', 'type.generic.placeholder', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (7, 'test_public_file_in_other_archive', 2, 'status.generic.ok', 'type.generic.placeholder', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (8, 'test_public_folder', 1, 'status.generic.ok', 'type.generic.placeholder', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (9, 'test_private_folder', 1, 'status.generic.ok', 'type.generic.placeholder', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (10, 'test_future_public_folder', 1, 'status.generic.ok', 'type.generic.placeholder', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (11, 'test_deleted_folder', 1, 'status.generic.ok', 'type.generic.placeholder', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (12, 'test_public_folder_in_other_archive', 2, 'status.generic.ok', 'type.generic.placeholder', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (13, 'test_unused_tag', 1, 'status.generic.ok', 'type.generic.placeholder', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); diff --git a/src/routes/index.ts b/src/routes/index.ts index 431859fe..09777447 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -3,11 +3,13 @@ import { healthController } from "../health"; import { directiveController } from "../directive"; import { legacyContactController } from "../legacy_contact"; import { accountController } from "../account"; +import { archiveController } from "../archive"; const apiRoutes = express.Router(); apiRoutes.get("/health", healthController.getHealth); apiRoutes.use("/directive", directiveController); apiRoutes.use("/legacy-contact", legacyContactController); apiRoutes.use("/account", accountController); +apiRoutes.use("/archive", archiveController); export { apiRoutes };