Skip to content

Commit

Permalink
Add admin endpoint to recalculate record thumbnails
Browse files Browse the repository at this point in the history
We have some records in our database with thumbnail urls in an outdated
format. We'd like to force the regeneration of those urls. This commit
adds an admin endpoint that does that for a given record ID.
  • Loading branch information
Liam Lloyd committed Apr 5, 2024
1 parent b59d518 commit a0124f0
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 a0124f0

Please sign in to comment.