Skip to content

Commit 1bb1f13

Browse files
Add upsert lambda
1 parent 518f65c commit 1bb1f13

File tree

23 files changed

+983
-103
lines changed

23 files changed

+983
-103
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: 127 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1+
import { InsertLetter, Letter, UpdateLetter, UpsertLetter } from "../types";
12
import { createTables, DBContext, deleteTables, setupDynamoDBContainer } from './db';
23
import { LetterRepository } from '../letter-repository';
3-
import { Letter } from '../types';
44
import { Logger } from 'pino';
55
import { createTestLogger, LogStream } from './logs';
66
import { PutCommand } from '@aws-sdk/lib-dynamodb';
7-
import { LetterDto } from '../../../../lambdas/api-handler/src/contracts/letters';
87

9-
function createLetter(supplierId: string, letterId: string, status: Letter['status'] = 'PENDING'): Omit<Letter, 'ttl' | 'supplierStatus' | 'supplierStatusSk'> {
8+
function createLetter(
9+
supplierId: string,
10+
letterId: string,
11+
status: Letter["status"] = "PENDING",
12+
): InsertLetter {
1013
return {
1114
id: letterId,
1215
supplierId: supplierId,
@@ -107,14 +110,14 @@ describe('LetterRepository', () => {
107110
await letterRepository.putLetter(letter);
108111
await checkLetterStatus('supplier1', 'letter1', 'PENDING');
109112

110-
const letterDto: LetterDto = {
113+
const updateLetter: UpdateLetter = {
111114
id: 'letter1',
112115
supplierId: 'supplier1',
113116
status: 'REJECTED',
114117
reasonCode: 'R01',
115118
reasonText: 'Reason text'
116119
};
117-
await letterRepository.updateLetterStatus(letterDto);
120+
await letterRepository.updateLetterStatus(updateLetter);
118121

119122
const updatedLetter = await letterRepository.getLetterById('supplier1', 'letter1');
120123
expect(updatedLetter.status).toBe('REJECTED');
@@ -132,7 +135,7 @@ describe('LetterRepository', () => {
132135
// Month is zero-indexed in JavaScript Date
133136
// Day is one-indexed
134137
jest.setSystemTime(new Date(2020, 1, 2));
135-
const letterDto: LetterDto = {
138+
const letterDto: UpdateLetter = {
136139
id: 'letter1',
137140
supplierId: 'supplier1',
138141
status: 'DELIVERED'
@@ -145,7 +148,7 @@ describe('LetterRepository', () => {
145148
});
146149

147150
test('can\'t update a letter that does not exist', async () => {
148-
const letterDto: LetterDto = {
151+
const letterDto: UpdateLetter = {
149152
id: 'letter1',
150153
supplierId: 'supplier1',
151154
status: 'DELIVERED'
@@ -160,7 +163,7 @@ describe('LetterRepository', () => {
160163
lettersTableName: 'nonexistent-table'
161164
});
162165

163-
const letterDto: LetterDto = {
166+
const letterDto: UpdateLetter = {
164167
id: 'letter1',
165168
supplierId: 'supplier1',
166169
status: 'DELIVERED'
@@ -190,7 +193,7 @@ describe('LetterRepository', () => {
190193
const pendingLetters = await letterRepository.getLettersByStatus('supplier1', 'PENDING');
191194
expect(pendingLetters.letters).toHaveLength(2);
192195

193-
const letterDto: LetterDto = {
196+
const letterDto: UpdateLetter = {
194197
id: 'letter1',
195198
supplierId: 'supplier1',
196199
status: 'DELIVERED'
@@ -367,4 +370,119 @@ describe('LetterRepository', () => {
367370
await expect(misconfiguredRepository.putLetterBatch([createLetter('supplier1', 'letter1')]))
368371
.rejects.toThrow('Cannot do operations on a non-existent table');
369372
});
373+
374+
test("successful upsert (update status) returns updated letter", async () => {
375+
const insertLetter: InsertLetter = createLetter("supplier1", "letter1");
376+
377+
const existingLetter = await letterRepository.putLetter(insertLetter);
378+
379+
const result = await letterRepository.upsertLetter({
380+
id: "letter1",
381+
supplierId: "supplier1",
382+
status: "REJECTED",
383+
reasonCode: "R01",
384+
reasonText: "R01 text",
385+
});
386+
387+
expect(result).toEqual(
388+
expect.objectContaining({
389+
id: "letter1",
390+
status: "REJECTED",
391+
specificationId: "specification1",
392+
groupId: "group1",
393+
reasonCode: "R01",
394+
reasonText: "R01 text",
395+
supplierId: "supplier1",
396+
url: "s3://bucket/letter1.pdf",
397+
supplierStatus: "supplier1#REJECTED",
398+
}),
399+
);
400+
expect(Date.parse(result.updatedAt)).toBeGreaterThan(
401+
Date.parse(existingLetter.updatedAt),
402+
);
403+
expect(Date.parse(result.updatedAt)).toBeLessThan(Date.now());
404+
expect(result.createdAt).toBe(existingLetter.createdAt);
405+
expect(result.createdAt).toBe(result.supplierStatusSk);
406+
expect(result.ttl).toBe(existingLetter.ttl);
407+
});
408+
409+
test("successful upsert (insert) returns created letter", async () => {
410+
const upsertLetter: UpsertLetter = {
411+
id: "letter1",
412+
status: "PENDING",
413+
specificationId: "specification1",
414+
groupId: "group1",
415+
supplierId: "supplier1",
416+
url: "s3://bucket/letter1.pdf",
417+
};
418+
419+
const beforeInsert = Date.now() - 1; // widen window
420+
421+
const result = await letterRepository.upsertLetter(upsertLetter);
422+
423+
expect(result).toEqual(
424+
expect.objectContaining({
425+
id: "letter1",
426+
status: "PENDING",
427+
specificationId: "specification1",
428+
groupId: "group1",
429+
supplierId: "supplier1",
430+
url: "s3://bucket/letter1.pdf",
431+
}),
432+
);
433+
434+
expect(Date.parse(result.updatedAt)).toBeGreaterThan(beforeInsert);
435+
expect(Date.parse(result.updatedAt)).toBeLessThan(Date.now());
436+
expect(result.createdAt).toBe(result.updatedAt);
437+
expect(result.supplierStatusSk).toBe(result.createdAt);
438+
});
439+
440+
test("unsuccessful upsert should throw error", async () => {
441+
const mockSend = jest.fn().mockResolvedValue({ Items: null });
442+
const mockDdbClient = { send: mockSend } as any;
443+
const repo = new LetterRepository(
444+
mockDdbClient,
445+
{ debug: jest.fn() } as any,
446+
{ lettersTableName: "letters", lettersTtlHours: 1 },
447+
);
448+
449+
await expect(
450+
repo.upsertLetter({
451+
id: "letter1",
452+
status: "PENDING",
453+
supplierId: "supplier1",
454+
}),
455+
).rejects.toThrow("upsertLetter: no attributes returned");
456+
});
457+
458+
test("successful upsert without status", async () => {
459+
const insertLetter: InsertLetter = createLetter("supplier1", "letter1");
460+
461+
const existingLetter = await letterRepository.putLetter(insertLetter);
462+
463+
const result = await letterRepository.upsertLetter({
464+
id: "letter1",
465+
supplierId: "supplier1",
466+
url: "s3://updatedBucket/letter1.pdf",
467+
});
468+
469+
expect(result).toEqual(
470+
expect.objectContaining({
471+
id: "letter1",
472+
status: "PENDING",
473+
specificationId: "specification1",
474+
groupId: "group1",
475+
supplierId: "supplier1",
476+
url: "s3://updatedBucket/letter1.pdf",
477+
supplierStatus: "supplier1#PENDING",
478+
}),
479+
);
480+
expect(Date.parse(result.updatedAt)).toBeGreaterThan(
481+
Date.parse(existingLetter.updatedAt),
482+
);
483+
expect(Date.parse(result.updatedAt)).toBeLessThan(Date.now());
484+
expect(result.createdAt).toBe(existingLetter.createdAt);
485+
expect(result.createdAt).toBe(result.supplierStatusSk);
486+
expect(result.ttl).toBe(existingLetter.ttl);
487+
});
370488
});

internal/datastore/src/letter-repository.ts

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,17 @@ import {
77
UpdateCommand,
88
UpdateCommandOutput
99
} from '@aws-sdk/lib-dynamodb';
10-
import { Letter, LetterBase, LetterSchema, LetterSchemaBase } from './types';
1110
import { Logger } from 'pino';
1211
import { z } from 'zod';
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>,
@@ -32,7 +39,7 @@ export class LetterRepository {
3239
readonly config: LetterRepositoryConfig) {
3340
}
3441

35-
async putLetter(letter: Omit<Letter, 'ttl' | 'supplierStatus' | 'supplierStatusSk'>): Promise<Letter> {
42+
async putLetter(letter: InsertLetter): Promise<Letter> {
3643
const letterDb: Letter = {
3744
...letter,
3845
supplierStatus: `${letter.supplierId}#${letter.status}`,
@@ -54,7 +61,7 @@ export class LetterRepository {
5461
return LetterSchema.parse(letterDb);
5562
}
5663

57-
async putLetterBatch(letters: Omit<Letter, 'ttl' | 'supplierStatus'| 'supplierStatusSk'>[]): Promise<void> {
64+
async putLetterBatch(letters: InsertLetter[]): Promise<void> {
5865
let lettersDb: Letter[] = [];
5966
for (let i = 0; i < letters.length; i++) {
6067

@@ -134,7 +141,7 @@ export class LetterRepository {
134141
}
135142
}
136143

137-
async updateLetterStatus(letterToUpdate: LetterDto): Promise<Letter> {
144+
async updateLetterStatus(letterToUpdate: UpdateLetter): Promise<Letter> {
138145
this.log.debug(`Updating letter ${letterToUpdate.id} to status ${letterToUpdate.status}`);
139146
let result: UpdateCommandOutput;
140147
try {
@@ -201,4 +208,92 @@ export class LetterRepository {
201208
}));
202209
return z.array(LetterSchemaBase).parse(result.Items ?? []);
203210
}
211+
212+
async upsertLetter(upsert: UpsertLetter): Promise<Letter> {
213+
const now = new Date();
214+
const ttl = Math.floor(
215+
now.valueOf() / 1000 + 60 * 60 * this.config.lettersTtlHours,
216+
);
217+
218+
const setParts: string[] = [];
219+
const exprAttrNames: Record<string, string> = {};
220+
const exprAttrValues: Record<string, any> = {};
221+
222+
// updateAt is always updated
223+
setParts.push("updatedAt = :updatedAt");
224+
exprAttrValues[":updatedAt"] = now.toISOString();
225+
226+
// ttl is always updated
227+
setParts.push("#ttl = :ttl");
228+
exprAttrNames["#ttl"] = "ttl";
229+
exprAttrValues[":ttl"] = ttl;
230+
231+
// createdAt only if first time
232+
setParts.push("createdAt = if_not_exists(createdAt, :createdAt)");
233+
exprAttrValues[":createdAt"] = now.toISOString();
234+
235+
// status and related supplierStatus if provided
236+
if (upsert.status !== undefined) {
237+
exprAttrNames["#status"] = "status";
238+
setParts.push("#status = :status");
239+
exprAttrValues[":status"] = upsert.status;
240+
241+
setParts.push("supplierStatus = :supplierStatus");
242+
exprAttrValues[":supplierStatus"] =
243+
`${upsert.supplierId}#${upsert.status}`;
244+
245+
// supplierStatusSk should replicate createdAt
246+
setParts.push(
247+
"supplierStatusSk = if_not_exists(supplierStatusSk, :supplierStatusSk)",
248+
);
249+
exprAttrValues[":supplierStatusSk"] = now.toISOString();
250+
}
251+
252+
// fields that could be updated
253+
254+
if (upsert.specificationId !== undefined) {
255+
setParts.push("specificationId = :specificationId");
256+
exprAttrValues[":specificationId"] = upsert.specificationId;
257+
}
258+
259+
if (upsert.url !== undefined) {
260+
setParts.push("#url = :url");
261+
exprAttrNames["#url"] = "url";
262+
exprAttrValues[":url"] = upsert.url;
263+
}
264+
265+
if (upsert.groupId !== undefined) {
266+
setParts.push("groupId = :groupId");
267+
exprAttrValues[":groupId"] = upsert.groupId;
268+
}
269+
270+
if (upsert.reasonCode !== undefined) {
271+
setParts.push("reasonCode = :reasonCode");
272+
exprAttrValues[":reasonCode"] = upsert.reasonCode;
273+
}
274+
if (upsert.reasonText !== undefined) {
275+
setParts.push("reasonText = :reasonText");
276+
exprAttrValues[":reasonText"] = upsert.reasonText;
277+
}
278+
279+
const updateExpression = `SET ${setParts.join(", ")}`;
280+
281+
const command = new UpdateCommand({
282+
TableName: this.config.lettersTableName,
283+
Key: { supplierId: upsert.supplierId, id: upsert.id },
284+
UpdateExpression: updateExpression,
285+
ExpressionAttributeNames: exprAttrNames,
286+
ExpressionAttributeValues: exprAttrValues,
287+
ReturnValues: "ALL_NEW",
288+
});
289+
290+
const result = await this.ddbClient.send(command);
291+
292+
if (!result.Attributes) {
293+
throw new Error("upsertLetter: no attributes returned");
294+
}
295+
296+
this.log.debug({ exprAttrValues }, `Upsert to letter=${upsert.id}`);
297+
return LetterSchema.parse(result.Attributes);
298+
}
204299
}

internal/datastore/src/types.ts

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

50+
export type InsertLetter = Omit<
51+
Letter,
52+
"ttl" | "supplierStatus" | "supplierStatusSk"
53+
>;
54+
export type UpdateLetter = {
55+
id: string;
56+
supplierId: string;
57+
status: Letter["status"];
58+
reasonCode?: string;
59+
reasonText?: string;
60+
};
61+
export type UpsertLetter = {
62+
id: string;
63+
supplierId: string;
64+
// fields that might set/overwrite
65+
status?: Letter["status"];
66+
specificationId?: string;
67+
groupId?: string;
68+
url?: string;
69+
reasonCode?: string;
70+
reasonText?: string;
71+
};
72+
5073
export const MISchemaBase = z.object({
5174
id: z.string(),
5275
lineItem: z.string(),

lambdas/api-handler/src/contracts/letters.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,17 @@ export const LetterStatusSchema = z.enum([
1515
'DELIVERED'
1616
]);
1717

18-
export const LetterDtoSchema = z.object({
19-
id: z.string(),
20-
status: LetterStatusSchema,
21-
supplierId: z.string(),
22-
specificationId: z.string().optional(),
23-
groupId: z.string().optional(),
24-
reasonCode: z.string().optional(),
25-
reasonText: z.string().optional(),
26-
}).strict();
18+
export const UpdateLetterSchema = z
19+
.object({
20+
id: z.string(),
21+
supplierId: z.string(),
22+
status: LetterStatusSchema,
23+
reasonCode: z.string().optional(),
24+
reasonText: z.string().optional(),
25+
})
26+
.strict();
2727

28-
export type LetterDto = z.infer<typeof LetterDtoSchema>;
28+
export type UpdateLetterCommand = z.infer<typeof UpdateLetterSchema>;
2929

3030
export const PatchLetterRequestResourceSchema = z.object({
3131
id: z.string(),

0 commit comments

Comments
 (0)