Skip to content

Commit

Permalink
Merge pull request #79 from PermanentOrg/per-9340_regenerate_record_t…
Browse files Browse the repository at this point in the history
…humbnails

Add admin endpoint to recalculate record thumbnails
  • Loading branch information
liam-lloyd authored Apr 11, 2024
2 parents 664b3b8 + a0124f0 commit ee2d416
Show file tree
Hide file tree
Showing 9 changed files with 234 additions and 2 deletions.
19 changes: 19 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,24 @@ account's status is `status.auth.ok` and its current `subject` is `null`
}
```

### POST `/admin/record/:recordId/recalculate_thumbnail

Queues a thumbnail regeneration task for the record given by `recordId`

- Headers: Authorization: Bearer \<JWT from FusionAuth>

- Request

```
{}
```

- Response

```
{}
```

## Directives

### POST `/directive`
Expand Down Expand Up @@ -402,6 +420,7 @@ account's status is `status.auth.ok` and its current `subject` is `null`
alreadyInvited: [string]
}
```

## Events

### POST `/event`
Expand Down
2 changes: 1 addition & 1 deletion packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"clear-database-ci": "psql postgresql://postgres:permanent@database:5432 -c 'DROP DATABASE IF EXISTS test_permanent;'",
"create-database-ci": "psql postgresql://postgres:permanent@database:5432 -c 'CREATE DATABASE test_permanent;'",
"set-up-database-ci": "pg_dump postgresql://postgres:permanent@database:5432/permanent --schema-only | psql postgresql://postgres:permanent@database:5432/test_permanent",
"test": "npm run start-containers && npm run clear-database && npm run create-database && npm run set-up-database && (cd ../..; docker compose run stela node --experimental-vm-modules ../../node_modules/jest/bin/jest.js -i)",
"test": "npm run start-containers && npm run clear-database && npm run create-database && npm run set-up-database && (cd ../..; docker compose run stela node --experimental-vm-modules ../../node_modules/jest/bin/jest.js -i --silent=false)",
"test-ci": "npm run clear-database-ci && npm run create-database-ci && npm run set-up-database-ci && node --experimental-vm-modules ../../node_modules/jest/bin/jest.js -i --coverage"
},
"dependencies": {
Expand Down
105 changes: 105 additions & 0 deletions packages/api/src/admin/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,3 +378,108 @@ describe("set_null_subjects", () => {
});
});
});

describe("/record/:recordId/recalculate_thumbnail", () => {
const agent = request(app);

const loadFixtures = async (): Promise<void> => {
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_folder_links");
};

const clearDatabase = async (): Promise<void> => {
await db.query(
"TRUNCATE account, archive, record, folder, folder_link CASCADE"
);
};

beforeEach(async () => {
(verifyAdminAuthentication as jest.Mock).mockImplementation(
(req: Request, __, next: NextFunction) => {
(
req.body as {
beginTimestamp: Date;
endTimestamp: Date;
emailFromAuthToken: string;
}
).emailFromAuthToken = "test@permanent.org";
next();
}
);
jest.clearAllMocks();
await clearDatabase();
await loadFixtures();
});

afterEach(async () => {
await clearDatabase();
});

test("should response with 401 if not authenticated as an admin", async () => {
(verifyAdminAuthentication as jest.Mock).mockImplementation(
(_, __, next: NextFunction) => {
next(new createError.Unauthorized("Invalid token"));
}
);
await agent
.post("/api/v2/admin/record/1/recalculate_thumbnail")
.expect(401);
});

test("should publish a message with correct parameters", async () => {
(publisherClient.batchPublishMessages as jest.Mock).mockResolvedValueOnce({
failedMessages: [],
messagesSent: 1,
});
await agent
.post("/api/v2/admin/record/1/recalculate_thumbnail")
.expect(200);

expect(publisherClient.batchPublishMessages).toHaveBeenCalled();
const publishMessages = (
(publisherClient.batchPublishMessages as jest.Mock).mock.calls[0] as [
string,
Message[]
]
)[1] as unknown as Message[];
if (publishMessages[0]) {
const publishBody = JSON.parse(publishMessages[0].body) as {
parameters: string[];
};
expect(publishBody.parameters.length).toBe(6);
expect(publishBody.parameters[1]).toBe("1");
expect(publishBody.parameters[2]).toBe("2");
expect(publishBody.parameters[3]).toBe("1");
} else {
expect(true).toBe(false);
}
});

test("should respond with 404 if the record doesn't exist", async () => {
await agent
.post("/api/v2/admin/record/1000/recalculate_thumbnail")
.expect(404);
});

test("should respond with 500 if the message fails to send", async () => {
jest
.spyOn(publisherClient, "batchPublishMessages")
.mockResolvedValueOnce({ failedMessages: ["1"], messagesSent: 0 });
await agent
.post("/api/v2/admin/record/1/recalculate_thumbnail")
.expect(500);
});

test("should respond with 500 and log the error if the database call fails", async () => {
const testError = new Error("out of cheese - redo from start");
jest.spyOn(db, "sql").mockRejectedValueOnce(testError);

await agent
.post("/api/v2/admin/record/1/recalculate_thumbnail")
.expect(500);
expect(logger.error).toHaveBeenCalledWith(testError);
});
});
18 changes: 18 additions & 0 deletions packages/api/src/admin/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { adminService } from "./service";
import { verifyAdminAuthentication } from "../middleware";
import {
validateRecalculateFolderThumbnailsRequest,
validateRecalculateRecordThumbnailRequest,
validateAccountSetNullSubjectsRequest,
} from "./validators";
import { isValidationError } from "../validators/validator_util";
Expand Down Expand Up @@ -53,3 +54,20 @@ adminController.post(
}
}
);

adminController.post(
"/record/:recordId/recalculate_thumbnail",
verifyAdminAuthentication,
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
// You couldn't actually call this endpoint with invalid params,
// because the route would not match. We validate solely to make the type checker happy.
// This is why there is no error handling for validation errors.
validateRecalculateRecordThumbnailRequest(req.params);
await adminService.recalculateRecordThumbnail(req.params.recordId);
res.status(200).json({});
} catch (err) {
next(err);
}
}
);
6 changes: 6 additions & 0 deletions packages/api/src/admin/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,9 @@ export interface Folder {
folderId: string;
archiveId: string;
}

export interface ArchiveRecord {
recordId: string;
parentFolderId: string;
archiveId: string;
}
11 changes: 11 additions & 0 deletions packages/api/src/admin/queries/get_record.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
SELECT
record.recordid AS "recordId",
record.archiveid AS "archiveId",
folder_link.parentfolderid AS "parentFolderId"
FROM
record
INNER JOIN
folder_link
ON record.recordid = folder_link.recordid
WHERE
record.recordid = :recordId;
42 changes: 41 additions & 1 deletion packages/api/src/admin/service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import createError from "http-errors";
import { logger } from "@stela/logger";
import { db } from "../database";
import type { Folder } from "./models";
import type { Folder, ArchiveRecord } from "./models";
import { publisherClient, lowPriorityTopicArn } from "../publisher_client";

const recalculateFolderThumbnails = async (
Expand Down Expand Up @@ -48,6 +48,45 @@ const recalculateFolderThumbnails = async (
};
};

const recalculateRecordThumbnail = async (recordId: string): Promise<void> => {
const recordResult = await db
.sql<ArchiveRecord>("admin.queries.get_record", {
recordId,
})
.catch((err) => {
logger.error(err);
throw new createError.InternalServerError("Failed to retrieve record");
});

if (!recordResult.rows[0]) {
throw new createError.NotFound("Record not found");
}

const publishResult = await publisherClient.batchPublishMessages(
lowPriorityTopicArn,
[
{
id: recordId,
body: JSON.stringify({
task: "task.thumbnail.record",
parameters: [
"Thumbnail_Redo",
recordId,
recordResult.rows[0].parentFolderId,
recordResult.rows[0].archiveId,
0,
new Date().toISOString(),
],
}),
},
]
);

if (publishResult.failedMessages.length > 0) {
throw new createError.InternalServerError("Failed to publish message");
}
};

const setNullAccountSubjects = async (
accounts: {
email: string;
Expand Down Expand Up @@ -82,5 +121,6 @@ const setNullAccountSubjects = async (

export const adminService = {
recalculateFolderThumbnails,
recalculateRecordThumbnail,
setNullAccountSubjects,
};
13 changes: 13 additions & 0 deletions packages/api/src/admin/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,16 @@ export function validateAccountSetNullSubjectsRequest(
throw validation.error;
}
}

export function validateRecalculateRecordThumbnailRequest(
data: unknown
): asserts data is { recordId: string } {
const validation = Joi.object()
.keys({
recordId: Joi.string().required(),
})
.validate(data);
if (validation.error) {
throw validation.error;
}
}
20 changes: 20 additions & 0 deletions packages/api/src/fixtures/create_test_folder_links.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
INSERT INTO
folder_link (
recordid,
parentfolderid,
archiveid,
position,
accessrole,
status,
type
)
VALUES
(
1,
2,
1,
1,
'access.role.owner',
'status.generic.ok',
'type.folder_link.private'
);

0 comments on commit ee2d416

Please sign in to comment.