import { err, ok, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { Transaction } from "../generated/kysely-tailordb"; import { NotDeliveredError, NotificationNotFoundError } from "../lib/errors.generated"; export interface MarkNotificationAsReadInput { notificationId: string; } /** * Adds READ (and SEEN) to a single caller-owned, deliverable Notification, stamping readAt and writing a READ audit row. Idempotent. */ export async function run( db: Transaction, input: MarkNotificationAsReadInput, ctx: CommandContext, ) { const { notificationId } = input; const notification = await db .selectFrom("Notification") .selectAll() .where("id", "=", notificationId) .forUpdate() .executeTakeFirst(); // A notification owned by another user is reported exactly like a missing // one so callers cannot enumerate foreign notification ids. if (!notification || notification.recipientUserId !== ctx.actorId) { return err(new NotificationNotFoundError(notificationId)); } if (notification.deliveryStatus !== "SENT" && notification.deliveryStatus !== "DELIVERED") { return err(new NotDeliveredError(notificationId)); } // Idempotent: row already READ -> return unchanged, do not re-stamp readAt if (notification.engagementStatuses.includes("READ")) { return ok({ notification }); } // Archived rows are terminal for engagement writes: return unchanged // (idempotent), mirroring the ARCHIVED skip in markAllNotificationsAsRead. if (notification.engagementStatuses.includes("ARCHIVED")) { return ok({ notification }); } const now = new Date(); const seenAlreadyPresent = notification.engagementStatuses.includes("SEEN"); // Build the next set: monotonic add of SEEN (if absent) and READ const nextStatuses = [...notification.engagementStatuses]; if (!seenAlreadyPresent) nextStatuses.push("SEEN"); nextStatuses.push("READ"); const updated = await db .updateTable("Notification") .set({ engagementStatuses: nextStatuses, // Stamp seenAt only on first SEEN addition; preserve otherwise seenAt: seenAlreadyPresent ? notification.seenAt : now, readAt: now, updatedAt: now, }) .where("id", "=", notificationId) .returningAll() .executeTakeFirstOrThrow(); await db .insertInto("NotificationDeliveryAudit") .values({ id: crypto.randomUUID(), notificationId, eventType: "READ", occurredAt: now, occurredBy: notification.recipientUserId, errorClass: null, errorDetail: null, createdAt: now, updatedAt: now, }) .execute(); return ok({ notification: updated }); }