Skip to content

Commit 1d53900

Browse files
Add upsert lambda
1 parent 089b325 commit 1d53900

File tree

23 files changed

+982
-110
lines changed

23 files changed

+982
-110
lines changed

infrastructure/terraform/components/api/module_lambda_upsert_letter.tf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ module "upsert_letter" {
2222
function_code_base_path = local.aws_lambda_functions_dir_path
2323
function_code_dir = "upsert-letter/dist"
2424
function_include_common = true
25-
handler_function_name = "handler"
25+
handler_function_name = "upsertLetterHandler"
2626
runtime = "nodejs22.x"
2727
memory = 128
2828
timeout = 5

internal/datastore/src/__test__/letter-repository.test.ts

Lines changed: 126 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,14 @@ import {
77
setupDynamoDBContainer,
88
} from "./db";
99
import { LetterRepository } from "../letter-repository";
10-
import { Letter } from "../types";
10+
import { InsertLetter, Letter, UpdateLetter, UpsertLetter } from "../types";
1111
import { LogStream, createTestLogger } from "./logs";
12-
import { LetterDto } from "../../../../lambdas/api-handler/src/contracts/letters";
1312

1413
function createLetter(
1514
supplierId: string,
1615
letterId: string,
1716
status: Letter["status"] = "PENDING",
18-
): Omit<Letter, "ttl" | "supplierStatus" | "supplierStatusSk"> {
17+
): InsertLetter {
1918
return {
2019
id: letterId,
2120
supplierId,
@@ -126,14 +125,14 @@ describe("LetterRepository", () => {
126125
await letterRepository.putLetter(letter);
127126
await checkLetterStatus("supplier1", "letter1", "PENDING");
128127

129-
const letterDto: LetterDto = {
128+
const updateLetter: UpdateLetter = {
130129
id: "letter1",
131130
supplierId: "supplier1",
132131
status: "REJECTED",
133132
reasonCode: "R01",
134133
reasonText: "Reason text",
135134
};
136-
await letterRepository.updateLetterStatus(letterDto);
135+
await letterRepository.updateLetterStatus(updateLetter);
137136

138137
const updatedLetter = await letterRepository.getLetterById(
139138
"supplier1",
@@ -159,7 +158,7 @@ describe("LetterRepository", () => {
159158
// Month is zero-indexed in JavaScript Date
160159
// Day is one-indexed
161160
jest.setSystemTime(new Date(2020, 1, 2));
162-
const letterDto: LetterDto = {
161+
const letterDto: UpdateLetter = {
163162
id: "letter1",
164163
supplierId: "supplier1",
165164
status: "DELIVERED",
@@ -175,13 +174,13 @@ describe("LetterRepository", () => {
175174
});
176175

177176
test("can't update a letter that does not exist", async () => {
178-
const letterDto: LetterDto = {
177+
const updateLetter: UpdateLetter = {
179178
id: "letter1",
180179
supplierId: "supplier1",
181180
status: "DELIVERED",
182181
};
183182
await expect(
184-
letterRepository.updateLetterStatus(letterDto),
183+
letterRepository.updateLetterStatus(updateLetter),
185184
).rejects.toThrow(
186185
"Letter with id letter1 not found for supplier supplier1",
187186
);
@@ -193,13 +192,13 @@ describe("LetterRepository", () => {
193192
lettersTableName: "nonexistent-table",
194193
});
195194

196-
const letterDto: LetterDto = {
195+
const updateLetter: UpdateLetter = {
197196
id: "letter1",
198197
supplierId: "supplier1",
199198
status: "DELIVERED",
200199
};
201200
await expect(
202-
misconfiguredRepository.updateLetterStatus(letterDto),
201+
misconfiguredRepository.updateLetterStatus(updateLetter),
203202
).rejects.toThrow("Cannot do operations on a non-existent table");
204203
});
205204

@@ -238,12 +237,12 @@ describe("LetterRepository", () => {
238237
);
239238
expect(pendingLetters.letters).toHaveLength(2);
240239

241-
const letterDto: LetterDto = {
240+
const updateLetter: UpdateLetter = {
242241
id: "letter1",
243242
supplierId: "supplier1",
244243
status: "DELIVERED",
245244
};
246-
await letterRepository.updateLetterStatus(letterDto);
245+
await letterRepository.updateLetterStatus(updateLetter);
247246
const remainingLetters = await letterRepository.getLettersByStatus(
248247
"supplier1",
249248
"PENDING",
@@ -457,4 +456,119 @@ describe("LetterRepository", () => {
457456
]),
458457
).rejects.toThrow("Cannot do operations on a non-existent table");
459458
});
459+
460+
test("successful upsert (update status) returns updated letter", async () => {
461+
const insertLetter: InsertLetter = createLetter("supplier1", "letter1");
462+
463+
const existingLetter = await letterRepository.putLetter(insertLetter);
464+
465+
const result = await letterRepository.upsertLetter({
466+
id: "letter1",
467+
supplierId: "supplier1",
468+
status: "REJECTED",
469+
reasonCode: "R01",
470+
reasonText: "R01 text",
471+
});
472+
473+
expect(result).toEqual(
474+
expect.objectContaining({
475+
id: "letter1",
476+
status: "REJECTED",
477+
specificationId: "specification1",
478+
groupId: "group1",
479+
reasonCode: "R01",
480+
reasonText: "R01 text",
481+
supplierId: "supplier1",
482+
url: "s3://bucket/letter1.pdf",
483+
supplierStatus: "supplier1#REJECTED",
484+
}),
485+
);
486+
expect(Date.parse(result.updatedAt)).toBeGreaterThan(
487+
Date.parse(existingLetter.updatedAt),
488+
);
489+
expect(Date.parse(result.updatedAt)).toBeLessThan(Date.now());
490+
expect(result.createdAt).toBe(existingLetter.createdAt);
491+
expect(result.createdAt).toBe(result.supplierStatusSk);
492+
expect(result.ttl).toBe(existingLetter.ttl);
493+
});
494+
495+
test("successful upsert (insert) returns created letter", async () => {
496+
const upsertLetter: UpsertLetter = {
497+
id: "letter1",
498+
status: "PENDING",
499+
specificationId: "specification1",
500+
groupId: "group1",
501+
supplierId: "supplier1",
502+
url: "s3://bucket/letter1.pdf",
503+
};
504+
505+
const beforeInsert = Date.now() - 1; // widen window
506+
507+
const result = await letterRepository.upsertLetter(upsertLetter);
508+
509+
expect(result).toEqual(
510+
expect.objectContaining({
511+
id: "letter1",
512+
status: "PENDING",
513+
specificationId: "specification1",
514+
groupId: "group1",
515+
supplierId: "supplier1",
516+
url: "s3://bucket/letter1.pdf",
517+
}),
518+
);
519+
520+
expect(Date.parse(result.updatedAt)).toBeGreaterThan(beforeInsert);
521+
expect(Date.parse(result.updatedAt)).toBeLessThan(Date.now());
522+
expect(result.createdAt).toBe(result.updatedAt);
523+
expect(result.supplierStatusSk).toBe(result.createdAt);
524+
});
525+
526+
test("unsuccessful upsert should throw error", async () => {
527+
const mockSend = jest.fn().mockResolvedValue({ Items: null });
528+
const mockDdbClient = { send: mockSend } as any;
529+
const repo = new LetterRepository(
530+
mockDdbClient,
531+
{ debug: jest.fn() } as any,
532+
{ lettersTableName: "letters", lettersTtlHours: 1 },
533+
);
534+
535+
await expect(
536+
repo.upsertLetter({
537+
id: "letter1",
538+
status: "PENDING",
539+
supplierId: "supplier1",
540+
}),
541+
).rejects.toThrow("upsertLetter: no attributes returned");
542+
});
543+
544+
test("successful upsert without status", async () => {
545+
const insertLetter: InsertLetter = createLetter("supplier1", "letter1");
546+
547+
const existingLetter = await letterRepository.putLetter(insertLetter);
548+
549+
const result = await letterRepository.upsertLetter({
550+
id: "letter1",
551+
supplierId: "supplier1",
552+
url: "s3://updatedBucket/letter1.pdf",
553+
});
554+
555+
expect(result).toEqual(
556+
expect.objectContaining({
557+
id: "letter1",
558+
status: "PENDING",
559+
specificationId: "specification1",
560+
groupId: "group1",
561+
supplierId: "supplier1",
562+
url: "s3://updatedBucket/letter1.pdf",
563+
supplierStatus: "supplier1#PENDING",
564+
}),
565+
);
566+
expect(Date.parse(result.updatedAt)).toBeGreaterThan(
567+
Date.parse(existingLetter.updatedAt),
568+
);
569+
expect(Date.parse(result.updatedAt)).toBeLessThan(Date.now());
570+
expect(result.createdAt).toBe(existingLetter.createdAt);
571+
expect(result.createdAt).toBe(result.supplierStatusSk);
572+
expect(result.ttl).toBe(existingLetter.ttl);
573+
});
460574
});

internal/datastore/src/letter-repository.ts

Lines changed: 100 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,15 @@ import {
99
} from "@aws-sdk/lib-dynamodb";
1010
import { Logger } from "pino";
1111
import { z } from "zod";
12-
import { Letter, LetterBase, LetterSchema, LetterSchemaBase } from "./types";
13-
import { LetterDto } from "../../../lambdas/api-handler/src/contracts/letters";
12+
import {
13+
InsertLetter,
14+
Letter,
15+
LetterBase,
16+
LetterSchema,
17+
LetterSchemaBase,
18+
UpdateLetter,
19+
UpsertLetter,
20+
} from "./types";
1421

1522
export type PagingOptions = Partial<{
1623
exclusiveStartKey: Record<string, any>;
@@ -33,9 +40,7 @@ export class LetterRepository {
3340
readonly config: LetterRepositoryConfig,
3441
) {}
3542

36-
async putLetter(
37-
letter: Omit<Letter, "ttl" | "supplierStatus" | "supplierStatusSk">,
38-
): Promise<Letter> {
43+
async putLetter(letter: InsertLetter): Promise<Letter> {
3944
const letterDb: Letter = {
4045
...letter,
4146
supplierStatus: `${letter.supplierId}#${letter.status}`,
@@ -66,9 +71,7 @@ export class LetterRepository {
6671
return LetterSchema.parse(letterDb);
6772
}
6873

69-
async putLetterBatch(
70-
letters: Omit<Letter, "ttl" | "supplierStatus" | "supplierStatusSk">[],
71-
): Promise<void> {
74+
async putLetterBatch(letters: InsertLetter[]): Promise<void> {
7275
let lettersDb: Letter[] = [];
7376
for (let i = 0; i < letters.length; i++) {
7477
const letter = letters[i];
@@ -161,7 +164,7 @@ export class LetterRepository {
161164
};
162165
}
163166

164-
async updateLetterStatus(letterToUpdate: LetterDto): Promise<Letter> {
167+
async updateLetterStatus(letterToUpdate: UpdateLetter): Promise<Letter> {
165168
this.log.debug(
166169
`Updating letter ${letterToUpdate.id} to status ${letterToUpdate.status}`,
167170
);
@@ -247,4 +250,92 @@ export class LetterRepository {
247250
);
248251
return z.array(LetterSchemaBase).parse(result.Items ?? []);
249252
}
253+
254+
async upsertLetter(upsert: UpsertLetter): Promise<Letter> {
255+
const now = new Date();
256+
const ttl = Math.floor(
257+
now.valueOf() / 1000 + 60 * 60 * this.config.lettersTtlHours,
258+
);
259+
260+
const setParts: string[] = [];
261+
const exprAttrNames: Record<string, string> = {};
262+
const exprAttrValues: Record<string, any> = {};
263+
264+
// updateAt is always updated
265+
setParts.push("updatedAt = :updatedAt");
266+
exprAttrValues[":updatedAt"] = now.toISOString();
267+
268+
// ttl is always updated
269+
setParts.push("#ttl = :ttl");
270+
exprAttrNames["#ttl"] = "ttl";
271+
exprAttrValues[":ttl"] = ttl;
272+
273+
// createdAt only if first time
274+
setParts.push("createdAt = if_not_exists(createdAt, :createdAt)");
275+
exprAttrValues[":createdAt"] = now.toISOString();
276+
277+
// status and related supplierStatus if provided
278+
if (upsert.status !== undefined) {
279+
exprAttrNames["#status"] = "status";
280+
setParts.push("#status = :status");
281+
exprAttrValues[":status"] = upsert.status;
282+
283+
setParts.push("supplierStatus = :supplierStatus");
284+
exprAttrValues[":supplierStatus"] =
285+
`${upsert.supplierId}#${upsert.status}`;
286+
287+
// supplierStatusSk should replicate createdAt
288+
setParts.push(
289+
"supplierStatusSk = if_not_exists(supplierStatusSk, :supplierStatusSk)",
290+
);
291+
exprAttrValues[":supplierStatusSk"] = now.toISOString();
292+
}
293+
294+
// fields that could be updated
295+
296+
if (upsert.specificationId !== undefined) {
297+
setParts.push("specificationId = :specificationId");
298+
exprAttrValues[":specificationId"] = upsert.specificationId;
299+
}
300+
301+
if (upsert.url !== undefined) {
302+
setParts.push("#url = :url");
303+
exprAttrNames["#url"] = "url";
304+
exprAttrValues[":url"] = upsert.url;
305+
}
306+
307+
if (upsert.groupId !== undefined) {
308+
setParts.push("groupId = :groupId");
309+
exprAttrValues[":groupId"] = upsert.groupId;
310+
}
311+
312+
if (upsert.reasonCode !== undefined) {
313+
setParts.push("reasonCode = :reasonCode");
314+
exprAttrValues[":reasonCode"] = upsert.reasonCode;
315+
}
316+
if (upsert.reasonText !== undefined) {
317+
setParts.push("reasonText = :reasonText");
318+
exprAttrValues[":reasonText"] = upsert.reasonText;
319+
}
320+
321+
const updateExpression = `SET ${setParts.join(", ")}`;
322+
323+
const command = new UpdateCommand({
324+
TableName: this.config.lettersTableName,
325+
Key: { supplierId: upsert.supplierId, id: upsert.id },
326+
UpdateExpression: updateExpression,
327+
ExpressionAttributeNames: exprAttrNames,
328+
ExpressionAttributeValues: exprAttrValues,
329+
ReturnValues: "ALL_NEW",
330+
});
331+
332+
const result = await this.ddbClient.send(command);
333+
334+
if (!result.Attributes) {
335+
throw new Error("upsertLetter: no attributes returned");
336+
}
337+
338+
this.log.debug({ exprAttrValues }, `Upsert to letter=${upsert.id}`);
339+
return LetterSchema.parse(result.Attributes);
340+
}
250341
}

internal/datastore/src/types.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,29 @@ export const LetterSchema = LetterSchemaBase.extend({
5858
export type Letter = z.infer<typeof LetterSchema>;
5959
export type LetterBase = z.infer<typeof LetterSchemaBase>;
6060

61+
export type InsertLetter = Omit<
62+
Letter,
63+
"ttl" | "supplierStatus" | "supplierStatusSk"
64+
>;
65+
export type UpdateLetter = {
66+
id: string;
67+
supplierId: string;
68+
status: Letter["status"];
69+
reasonCode?: string;
70+
reasonText?: string;
71+
};
72+
export type UpsertLetter = {
73+
id: string;
74+
supplierId: string;
75+
// fields that might set/overwrite
76+
status?: Letter["status"];
77+
specificationId?: string;
78+
groupId?: string;
79+
url?: string;
80+
reasonCode?: string;
81+
reasonText?: string;
82+
};
83+
6184
export const MISchemaBase = z.object({
6285
id: z.string(),
6386
lineItem: z.string(),

0 commit comments

Comments
 (0)