diff --git a/internal/datastore/src/__test__/letter-queue-repository.test.ts b/internal/datastore/src/__test__/letter-queue-repository.test.ts index bcac9c20b..e5869fe97 100644 --- a/internal/datastore/src/__test__/letter-queue-repository.test.ts +++ b/internal/datastore/src/__test__/letter-queue-repository.test.ts @@ -76,6 +76,7 @@ describe("LetterQueueRepository", () => { expect(pendingLetter.queueSortOrderSk).toBe( "10-2026-03-04T13:15:45.000Z", ); + expect(pendingLetter.lastReadTimestamp).toBeUndefined(); expect(await letterExists(db, "supplier1", "letter1")).toBe(true); }); @@ -164,9 +165,10 @@ describe("LetterQueueRepository", () => { it("filters by visibilityTimestamp", async () => { const pendingLetter = createLetter(); await letterQueueRepository.putLetter(createLetter()); - await letterQueueRepository.updateVisibilityTimestamp( + await letterQueueRepository.updateTimestamps( pendingLetter, new Date(Date.now() + 600_000), + new Date(Date.now()), ); const letters = await letterQueueRepository.getLetters("supplier1", 1); @@ -259,9 +261,10 @@ describe("LetterQueueRepository", () => { await letterQueueRepository.putLetter( createLetter({ letterId: "fourth-letter" }), ); - await letterQueueRepository.updateVisibilityTimestamp( + await letterQueueRepository.updateTimestamps( createLetter({ letterId: "second-letter" }), new Date(Date.now() + 600_000), + new Date(Date.now()), ); const letters = await letterQueueRepository.getLetters("supplier1", 3); @@ -301,24 +304,27 @@ describe("LetterQueueRepository", () => { }); }); - describe("updateVisibilityTimestamp", () => { - it("updates the visibilityTimestamp on an existing letter", async () => { + describe("updateTimestamps", () => { + it("updates the visibilityTimestamp and lastUpdatedTimestamp on an existing letter", async () => { const pendingLetter = await letterQueueRepository.putLetter(createLetter()); - await letterQueueRepository.updateVisibilityTimestamp( + await letterQueueRepository.updateTimestamps( pendingLetter, new Date("2026-03-04T13:15:45.000Z"), + new Date("2026-10-15T10:17:45.000Z"), ); const letter = await getLetter(db, "supplier1", "letter1"); expect(letter?.visibilityTimestamp).toBe("2026-03-04T13:15:45.000Z"); + expect(letter?.lastReadTimestamp).toBe("2026-10-15T10:17:45.000Z"); }); it("does nothing when the letter does not exist", async () => { - await letterQueueRepository.updateVisibilityTimestamp( + await letterQueueRepository.updateTimestamps( createLetter(), new Date(), + new Date(), ); expect(await letterExists(db, "supplier1", "letter1")).toBe(false); @@ -334,9 +340,10 @@ describe("LetterQueueRepository", () => { }, ); await expect( - misconfiguredRepository.updateVisibilityTimestamp( + misconfiguredRepository.updateTimestamps( createLetter(), new Date(), + new Date(), ), ).rejects.toThrow("Cannot do operations on a non-existent table"); }); diff --git a/internal/datastore/src/letter-queue-repository.ts b/internal/datastore/src/letter-queue-repository.ts index d404cb359..ec7dfa25a 100644 --- a/internal/datastore/src/letter-queue-repository.ts +++ b/internal/datastore/src/letter-queue-repository.ts @@ -127,9 +127,10 @@ export default class LetterQueueRepository { return letters.slice(0, limit); } - async updateVisibilityTimestamp( + async updateTimestamps( pendingLetter: PendingLetterBase, - timestamp: Date, + visibilityTimestamp: Date, + lastReadTimestamp: Date, ): Promise { try { await this.ddbClient.send( @@ -139,10 +140,12 @@ export default class LetterQueueRepository { supplierId: pendingLetter.supplierId, letterId: pendingLetter.letterId, }, - UpdateExpression: "SET visibilityTimestamp = :ts", + UpdateExpression: + "SET visibilityTimestamp = :visibilityTs, lastReadTimestamp = :lastReadTs", ConditionExpression: "attribute_exists(letterId)", ExpressionAttributeValues: { - ":ts": timestamp.toISOString(), + ":visibilityTs": visibilityTimestamp.toISOString(), + ":lastReadTs": lastReadTimestamp.toISOString(), }, }), ); diff --git a/internal/datastore/src/types.ts b/internal/datastore/src/types.ts index 310196d1b..ddb762b6d 100644 --- a/internal/datastore/src/types.ts +++ b/internal/datastore/src/types.ts @@ -94,6 +94,7 @@ export const PendingLetterSchemaBase = z.object({ export const PendingLetterSchema = PendingLetterSchemaBase.extend({ queueTimestamp: z.string(), visibilityTimestamp: z.string(), + lastReadTimestamp: z.string().optional(), queueSortOrderSk: z.string().describe("Secondary index SK"), priority: z.int().min(0).max(99).optional(), ttl: z.int(), diff --git a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts index c1537f0aa..f24fb23a7 100644 --- a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts +++ b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts @@ -71,7 +71,7 @@ describe("getPendingLetters", () => { groupId: "g1", }, ]), - updateVisibilityTimestamp: jest.fn(), + updateTimestamps: jest.fn(), }; const result = await getPendingLetters( "supplier1", @@ -80,15 +80,17 @@ describe("getPendingLetters", () => { 600, ); expect(mockRepo.getLetters).toHaveBeenCalledWith("supplier1", 10); - expect(mockRepo.updateVisibilityTimestamp).toHaveBeenNthCalledWith( + expect(mockRepo.updateTimestamps).toHaveBeenNthCalledWith( 1, expect.objectContaining({ supplierId: "supplier1", letterId: "id1" }), new Date("2026-03-04T13:25:45.000Z"), + new Date("2026-03-04T13:15:45.000Z"), ); - expect(mockRepo.updateVisibilityTimestamp).toHaveBeenNthCalledWith( + expect(mockRepo.updateTimestamps).toHaveBeenNthCalledWith( 2, expect.objectContaining({ supplierId: "supplier1", letterId: "id2" }), new Date("2026-03-04T13:25:45.000Z"), + new Date("2026-03-04T13:15:45.000Z"), ); expect(result).toEqual([ { id: "id1", status: "PENDING", specificationId: "s1", groupId: "g1" }, diff --git a/lambdas/api-handler/src/services/letter-operations.ts b/lambdas/api-handler/src/services/letter-operations.ts index b0053d8dc..66030ff11 100644 --- a/lambdas/api-handler/src/services/letter-operations.ts +++ b/lambdas/api-handler/src/services/letter-operations.ts @@ -49,13 +49,19 @@ export const getPendingLetters = async ( const CONCURRENCY = 5; const pendingLetters = await letterQueueRepo.getLetters(supplierId, limit); - const timestamp = new Date(Date.now() + visibilityTimeout * 1000); + const now = Date.now(); + const visibilityTimestamp = new Date(now + visibilityTimeout * 1000); + const lastReadTimestamp = new Date(now); for (let i = 0; i < pendingLetters.length; i += CONCURRENCY) { const window = pendingLetters.slice(i, i + CONCURRENCY); await Promise.all( window.map((letter) => - letterQueueRepo.updateVisibilityTimestamp(letter, timestamp), + letterQueueRepo.updateTimestamps( + letter, + visibilityTimestamp, + lastReadTimestamp, + ), ), ); } diff --git a/tests/component-tests/letterQueue-tests/queue-operations.spec.ts b/tests/component-tests/letterQueue-tests/queue-operations.spec.ts index 4db3f80fd..ff97870cb 100644 --- a/tests/component-tests/letterQueue-tests/queue-operations.spec.ts +++ b/tests/component-tests/letterQueue-tests/queue-operations.spec.ts @@ -107,7 +107,7 @@ test.describe("Letter Queue Tests", () => { await pollUpsertLetterLogForWarning("Letter already exists", letterId); }); - test("Verify if VisibilityTimeStamp is updated when Get /letters endpoint is called and subsequent calls returns no data until VisibilityTimeStamp is reached", async ({ + test("Verify if TimeStamps are updated when Get /letters endpoint is called and subsequent calls returns no data until VisibilityTimeStamp is reached", async ({ request, }) => { const letterId = randomUUID(); @@ -125,6 +125,7 @@ test.describe("Letter Queue Tests", () => { expect(letters).toHaveLength(1); const letter = letters[0]; expect(letter.visibilityTimestamp).toBe(letter.queueTimestamp); + expect(letter.lastReadTimestamp).toBeUndefined(); logger.info( "Visibility timestamp is same as queue timestamp before calling Get /letters endpoint", ); @@ -155,10 +156,23 @@ test.describe("Letter Queue Tests", () => { lettersAfterGet[0].visibilityTimestamp, ).getTime(); + if (lettersAfterGet[0].lastReadTimestamp === undefined) { + throw new Error( + "Expected lastReadTimestamp to be set after Get /letters", + ); + } + const lastReadTimestampAfterGet = new Date( + lettersAfterGet[0].lastReadTimestamp, + ).getTime(); + // Verify visibility was pushed forward by at least the configured timeout expect( visibilityTimestampAfterGet - visibilityTimestampBeforeGet, ).toBeGreaterThanOrEqual(VISIBILITY_TIMEOUT_SECONDS * 1000); + // Verify that lastReadTimestamp is set correctly + expect(lastReadTimestampAfterGet).toEqual( + visibilityTimestampAfterGet - VISIBILITY_TIMEOUT_SECONDS * 1000, + ); const { responseBody: secondResponseBody, statusCode: secondStatusCode } = await getLettersWithRetry(request, baseUrl, headers, { diff --git a/tests/helpers/generate-fetch-test-data.ts b/tests/helpers/generate-fetch-test-data.ts index c033bd68a..a4df4ce06 100644 --- a/tests/helpers/generate-fetch-test-data.ts +++ b/tests/helpers/generate-fetch-test-data.ts @@ -39,6 +39,7 @@ export const PendingLetterSchema = z.object({ letterId: z.string(), queueTimestamp: z.string(), visibilityTimestamp: z.string(), + lastReadTimestamp: z.string().optional(), queueSortOrderSk: z.string().describe("Secondary index SK"), priority: z.int().min(0).max(99).optional(), });