diff --git a/src/lib/jobs/__tests__/medication-inventory-expire.test.ts b/src/lib/jobs/__tests__/medication-inventory-expire.test.ts new file mode 100644 index 000000000..0a81312dd --- /dev/null +++ b/src/lib/jobs/__tests__/medication-inventory-expire.test.ts @@ -0,0 +1,24 @@ +/** + * v1.4.25 W19b — module-shape contract for the + * medication-inventory-expire job. The actual sweep logic lives in + * `expireStaleInUseItems` (covered by + * `src/lib/medications/inventory/__tests__/service.test.ts`); this + * test pins the queue name + cron schedule so a typo in the worker + * registration is caught at the unit-test level. + */ +import { describe, expect, it } from "vitest"; + +import { + MEDICATION_INVENTORY_EXPIRE_QUEUE, + MEDICATION_INVENTORY_EXPIRE_CRON, +} from "@/lib/jobs/medication-inventory-expire"; + +describe("medication-inventory-expire module", () => { + it("exports the canonical queue name", () => { + expect(MEDICATION_INVENTORY_EXPIRE_QUEUE).toBe("medication-inventory-expire"); + }); + + it("schedules at 03:30 Europe/Berlin", () => { + expect(MEDICATION_INVENTORY_EXPIRE_CRON).toBe("30 3 * * *"); + }); +}); diff --git a/src/lib/jobs/medication-inventory-expire.ts b/src/lib/jobs/medication-inventory-expire.ts new file mode 100644 index 000000000..dfa94fbb5 --- /dev/null +++ b/src/lib/jobs/medication-inventory-expire.ts @@ -0,0 +1,34 @@ +/** + * v1.4.25 W19b — pg-boss helper for the daily + * `medication-inventory-expire` queue. The cron at 03:00 Europe/Berlin + * (interleaved with the other 03:xx cleanup crons) sweeps every user + * for IN_USE pens whose 30-day in-use window has lapsed and flips + * their state to EXPIRED. + * + * The expire pass is idempotent — re-running it within the same day + * only updates rows that the previous pass missed (because they hit + * 30 days between the two runs). EXPIRED is a terminal-ish state in + * the state machine; once flipped, a row stays EXPIRED until the + * user explicitly deletes it or marks it USED_UP. + * + * Notification surface: opt-in only. The W19b scope deliberately + * leaves the push notification disabled by default — Marc directive + * "notifications saturate the lock screen on first sync" applies + * here too. Users opt in via Settings → Notifications (a future + * preference toggle); until then the job is silent and the user + * sees the EXPIRED badge in the UI on next page-load. + */ + +export const MEDICATION_INVENTORY_EXPIRE_QUEUE = "medication-inventory-expire"; + +/** + * Cron schedule (Europe/Berlin) for the daily expire pass. 03:00 + * matches the idempotency-cleanup slot and lands inside the existing + * 02:xx–03:xx maintenance window. Co-locating with the other cleanup + * crons keeps the late-night CPU profile coherent. + */ +export const MEDICATION_INVENTORY_EXPIRE_CRON = "30 3 * * *"; + +export interface MedicationInventoryExpirePayload { + triggeredAt: string; +} diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index 3652930b3..0316ab45c 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -40,6 +40,12 @@ import { type PrDetectionPayload, } from "@/lib/jobs/pr-detection"; import { detectPersonalRecordsForUser } from "@/lib/personal-records/pr-detection-worker"; +import { + MEDICATION_INVENTORY_EXPIRE_QUEUE, + MEDICATION_INVENTORY_EXPIRE_CRON, + type MedicationInventoryExpirePayload, +} from "@/lib/jobs/medication-inventory-expire"; +import { expireStaleInUseItems } from "@/lib/medications/inventory/service"; import { rotateLegacyMoodLogSecrets } from "@/lib/moodlog-secret"; import { deleteMessage } from "@/lib/telegram"; import { decrypt, encrypt } from "@/lib/crypto"; @@ -1171,6 +1177,29 @@ async function handlePrDetection( } } +/** + * v1.4.25 W19b — daily expire-stale pass for `MedicationInventoryItem` + * rows. Flips IN_USE pens whose 30-day window has lapsed to EXPIRED + * via the pure state-machine evaluator. + */ +async function handleMedicationInventoryExpire( + jobs: Job[], +) { + void jobs; + await withBackgroundEvent("job.medication_inventory_expire", async (evt) => { + try { + const count = await expireStaleInUseItems({ nowMs: Date.now() }); + evt.addMeta("inventory_expired_count", count); + } catch (err) { + evt.addWarning( + `medication-inventory-expire failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + }); +} + async function handleOffhostBackup(jobs: Job[]) { void jobs; await withBackgroundEvent("job.offhost_backup", async (evt) => { @@ -1421,6 +1450,7 @@ export async function startReminderWorker() { HOST_METRIC_QUEUE, FEEDBACK_AGGREGATOR_QUEUE, PR_DETECTION_QUEUE, + MEDICATION_INVENTORY_EXPIRE_QUEUE, ]; for (const q of allQueues) { @@ -1453,6 +1483,11 @@ export async function startReminderWorker() { // every user; per-user push-suppression cannot apply on the cron // path (the silent flag is set by the ingest hooks). [PR_DETECTION_QUEUE, PR_DETECTION_FALLBACK_CRON], + // v1.4.25 W19b — daily expire-stale pass for the per-pen inventory + // entities. Flips IN_USE rows whose 30-day clock has blown to + // EXPIRED at 03:30 Europe/Berlin (in the existing 02:xx–03:xx + // maintenance window, right after idempotency-cleanup). + [MEDICATION_INVENTORY_EXPIRE_QUEUE, MEDICATION_INVENTORY_EXPIRE_CRON], ]; for (const [name, cron] of schedules) { @@ -1560,6 +1595,11 @@ export async function startReminderWorker() { { localConcurrency: PR_DETECTION_CONCURRENCY }, handlePrDetection, ); + await boss.work( + MEDICATION_INVENTORY_EXPIRE_QUEUE, + { localConcurrency: 1 }, + handleMedicationInventoryExpire, + ); return boss; }