Skip to content

Commit d801d7e

Browse files
committed
Add archiveId filter to GET records
This allows users to load records by archiveID, which will return all records in a given archive. Issue #617 Support loading records by archive ID
1 parent 6194e50 commit d801d7e

File tree

7 files changed

+113
-24
lines changed

7 files changed

+113
-24
lines changed

packages/api/docs/src/paths/record.yaml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,22 @@ records:
44
parameters:
55
- name: recordIds
66
in: query
7-
required: true
7+
required: false
8+
description: |
9+
An array of record IDs to retrieve.
10+
At least one of recordIds or archiveId must be provided.
811
schema:
912
type: array
1013
items:
1114
type: string
15+
- name: archiveId
16+
in: query
17+
required: false
18+
description: |
19+
An archive ID to filter records by.
20+
At least one of recordIds or archiveId must be provided.
21+
schema:
22+
type: string
1223
security:
1324
- {}
1425
- bearerHttpAuthentication: []

packages/api/src/folder/service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ export const getFolderChildren = async (
165165
recordIds: result.rows
166166
.filter((item) => item.item_type === "record")
167167
.map((record) => record.id),
168+
archiveId: undefined,
168169
accountEmail: email,
169170
shareToken,
170171
});

packages/api/src/record/controller.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,62 @@ describe("GET /records", () => {
446446
await agent.get("/api/v2/records?recordIds[]=14").expect(500);
447447
expect(logger.error).toHaveBeenCalledWith(testError);
448448
});
449+
test("expect to return records filtered by archiveId", async () => {
450+
const response = await agent
451+
.get("/api/v2/records?archiveId=1")
452+
.expect(200);
453+
const { body: records } = response as { body: ArchiveRecord[] };
454+
const recordIds = records.map((record) => record.recordId);
455+
expect(recordIds).toContain("1");
456+
expect(recordIds).toContain("2");
457+
expect(recordIds).toContain("3");
458+
expect(recordIds).toContain("8");
459+
expect(recordIds).toContain("9");
460+
expect(recordIds).not.toContain("4");
461+
expect(recordIds).not.toContain("5");
462+
expect(recordIds).not.toContain("12");
463+
expect(recordIds).not.toContain("14");
464+
});
465+
test("expect archiveId and recordIds to act as an AND filter", async () => {
466+
const response = await agent
467+
.get("/api/v2/records?archiveId=1&recordIds[]=1")
468+
.expect(200);
469+
const { body: records } = response as { body: ArchiveRecord[] };
470+
expect(records.length).toEqual(1);
471+
expect(records[0]?.recordId).toEqual("1");
472+
});
473+
test("expect archiveId with no matching records to return empty array", async () => {
474+
const response = await agent
475+
.get("/api/v2/records?archiveId=9999")
476+
.expect(200);
477+
const { body: records } = response as { body: ArchiveRecord[] };
478+
expect(records.length).toEqual(0);
479+
});
480+
test("expect archiveId for non-owned archive to return only public records", async () => {
481+
const response = await agent
482+
.get("/api/v2/records?archiveId=2")
483+
.expect(200);
484+
const { body: records } = response as { body: ArchiveRecord[] };
485+
const recordIds = records.map((record) => record.recordId);
486+
expect(recordIds).toContain("5");
487+
expect(recordIds).toContain("6");
488+
expect(recordIds).not.toContain("7");
489+
});
490+
test("expect archiveId query without recordIds still requires archiveId", async () => {
491+
await agent.get("/api/v2/records").expect(400);
492+
});
493+
test("expect return records by archiveId for a public record when not logged in", async () => {
494+
mockExtractUserEmailFromAuthToken();
495+
const response = await agent
496+
.get("/api/v2/records?archiveId=1")
497+
.expect(200);
498+
const { body: records } = response as { body: ArchiveRecord[] };
499+
const recordIds = records.map((record) => record.recordId);
500+
expect(recordIds).toContain("1");
501+
expect(recordIds).toContain("8");
502+
expect(recordIds).not.toContain("2");
503+
expect(recordIds).not.toContain("3");
504+
});
449505
});
450506

451507
describe("PATCH /records", () => {

packages/api/src/record/controller.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ recordController.get(
3434
validateGetRecordQuery(req.query);
3535
const records = await getRecordById({
3636
recordIds: req.query.recordIds,
37+
archiveId: req.query.archiveId,
3738
accountEmail: req.body.emailFromAuthToken,
3839
shareToken: req.body.shareToken,
3940
});
@@ -60,6 +61,7 @@ recordController.patch(
6061
const recordId = await patchRecord(req.params.recordId, req.body);
6162
const record = await getRecordById({
6263
recordIds: [recordId],
64+
archiveId: undefined,
6365
accountEmail: req.body.emailFromAuthToken,
6466
});
6567

packages/api/src/record/queries/get_record_by_id.sql

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
1-
WITH RECURSIVE aggregated_files AS (
1+
WITH RECURSIVE candidate_records AS (
2+
SELECT recordid
3+
FROM record
4+
WHERE
5+
(:recordIds::TEXT[] IS NULL OR recordid = any(:recordIds))
6+
AND (:archiveId::TEXT IS NULL OR archiveid = :archiveId::INT)
7+
AND status != 'status.generic.deleted'
8+
),
9+
10+
aggregated_files AS (
211
(SELECT
312
record_file.recordid,
413
array_agg(jsonb_build_object(
@@ -26,7 +35,7 @@ WITH RECURSIVE aggregated_files AS (
2635
ON record_file.fileid = file.fileid
2736
WHERE
2837
file.status != 'status.generic.deleted'
29-
AND record_file.recordid = any(:recordIds)
38+
AND record_file.recordid IN (SELECT recordid FROM candidate_records)
3039
GROUP BY record_file.recordid)
3140
),
3241

@@ -51,7 +60,7 @@ aggregated_tags AS (
5160
WHERE
5261
tag_link.reftable = 'record'
5362
AND tag_link.status = 'status.generic.ok'
54-
AND tag_link.refid = any(:recordIds)
63+
AND tag_link.refid IN (SELECT recordid FROM candidate_records)
5564
GROUP BY tag_link.refid
5665
),
5766

@@ -87,7 +96,7 @@ aggregated_shares AS (
8796
AND profile_item.status = 'status.generic.ok'
8897
AND profile_item.string1 IS NOT NULL
8998
AND share.status != 'status.generic.deleted'
90-
AND folder_link.recordid = any(:recordIds)
99+
AND folder_link.recordid IN (SELECT recordid FROM candidate_records)
91100
GROUP BY share.folder_linkid
92101
),
93102

@@ -110,7 +119,7 @@ ancestor_unrestricted_share_tokens (
110119
OR shareby_url.expiresdt > current_timestamp
111120
)
112121
WHERE
113-
folder_link.recordid = any(:recordIds)
122+
folder_link.recordid IN (SELECT recordid FROM candidate_records)
114123
UNION
115124
SELECT
116125
folder_link.parentfolder_linkid,
@@ -269,7 +278,7 @@ LEFT JOIN
269278
aggregated_ancestor_unrestricted_share_tokens
270279
ON record.recordid = aggregated_ancestor_unrestricted_share_tokens.recordid
271280
WHERE
272-
record.recordid = any(:recordIds)
281+
record.recordid IN (SELECT recordid FROM candidate_records)
273282
AND (
274283
record_account.primaryemail = :accountEmail
275284
OR share_account.primaryemail = :accountEmail

packages/api/src/record/service.ts

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@ import { shareLinkService } from "../share_link/service";
1212
import type { ShareLink } from "../share_link/models";
1313

1414
export const getRecordById = async (requestQuery: {
15-
recordIds: string[];
15+
recordIds: string[] | undefined;
16+
archiveId: string | undefined;
1617
accountEmail: string | undefined;
1718
shareToken?: string | undefined;
1819
}): Promise<ArchiveRecord[]> => {
1920
const record = await db
2021
.sql<ArchiveRecordRow>("record.queries.get_record_by_id", {
21-
recordIds: requestQuery.recordIds,
22+
recordIds: requestQuery.recordIds ?? null,
23+
archiveId: requestQuery.archiveId ?? null,
2224
accountEmail: requestQuery.accountEmail,
2325
shareToken: requestQuery.shareToken,
2426
})
@@ -32,18 +34,24 @@ export const getRecordById = async (requestQuery: {
3234
imageRatio: +(row.imageRatio ?? 0),
3335
}));
3436

35-
// Our API contract remains that the order in which this endpoint returns
36-
// records is undefined. This ordering logic is implemented as a hotfix, and
37-
// should be removed when our clients no longer rely on this endpoint returning
38-
// records in a particular order.
39-
const recordsById = new Map<string, ArchiveRecord>();
40-
records.forEach((record: ArchiveRecord) =>
41-
recordsById.set(record.recordId, record),
42-
);
43-
const recordsInOrder = requestQuery.recordIds
44-
.map((recordId: string) => recordsById.get(recordId))
45-
.filter((record: ArchiveRecord | undefined) => record !== undefined);
46-
return recordsInOrder;
37+
if (requestQuery.recordIds !== undefined) {
38+
// Our API contract remains that the order in which this endpoint returns
39+
// records is undefined. This ordering logic is implemented as a hotfix, and
40+
// should be removed when our clients no longer rely on this endpoint returning
41+
// records in a particular order.
42+
const recordsById = new Map<string, ArchiveRecord>();
43+
records.forEach((archiveRecord: ArchiveRecord) =>
44+
recordsById.set(archiveRecord.recordId, archiveRecord),
45+
);
46+
const recordsInOrder = requestQuery.recordIds
47+
.map((recordId: string) => recordsById.get(recordId))
48+
.filter(
49+
(archiveRecord: ArchiveRecord | undefined) =>
50+
archiveRecord !== undefined,
51+
);
52+
return recordsInOrder;
53+
}
54+
return records;
4755
};
4856

4957
const validateCanPatchRecord = async (

packages/api/src/record/validators.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ import { fieldsFromUserAuthentication } from "../validators";
55

66
export const validateGetRecordQuery: (
77
data: unknown,
8-
) => asserts data is { recordIds: string[] } = (
8+
) => asserts data is { recordIds?: string[]; archiveId?: string } = (
99
data: unknown,
10-
): asserts data is { recordIds: string[] } => {
10+
): asserts data is { recordIds?: string[]; archiveId?: string } => {
1111
const validation = Joi.object()
1212
.keys({
13-
recordIds: Joi.array().items(Joi.string().required()).required(),
13+
recordIds: Joi.array().items(Joi.string().required()),
14+
archiveId: Joi.string(),
1415
})
16+
.or("recordIds", "archiveId")
1517
.validate(data);
1618
if (validation.error !== undefined) {
1719
throw validation.error;

0 commit comments

Comments
 (0)