feat(jobs): daily medication-inventory expire-stale queue (03:00 cron)

This commit is contained in:
Marc-André Bombeck committed 2026-05-14 18:56:46 +02:00
1 parent a0b9c11e68
commit 7f133a1aa7
3 files changed
+98

No files matched your search

@@ -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 * * *");
});
});
@@ -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;
}
+40
View File
@@ -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<MedicationInventoryExpirePayload>[],
) {
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<OffhostBackupPayload>[]) {
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<MedicationInventoryExpirePayload>(
MEDICATION_INVENTORY_EXPIRE_QUEUE,
{ localConcurrency: 1 },
handleMedicationInventoryExpire,
);
return boss;
}