# Notifications

Personal authenticated inbox is mounted at `/api/v1/notifications`; see `notifications.openapi.ts` for request/response schemas. It follows cookie authentication, trusted mutation origin and standard success/error envelopes. GET has no acknowledgement or generation side effects.

List supports q, category=all|warning|system, status=all|read|unread, dateFrom/dateTo (inclusive facility dates), page and pageSize (max100). Page uses10; header uses3 with no filters. Sort is createdAt/id descending. Summary counts all nondeleted unread entries. PATCH /:id/read and /read-all explicitly acknowledge. DELETE /:id hides only that recipient; POST /:id/restore preserves readAt. GET /export returns every filtered row up to10000; larger results return422. GET /:id never grants another module's permissions. Unknown/unowned ids return404.

Calibration kind and calibration-linked WO entries are excluded; historical data is retained. The pending-approval/low-stock recipient set is active ADMIN/SUPERVISOR. Urgent/overdue/PM includes TECHNICIAN. VIEWER has only previously delivered personal entries. Public requester names have no inbox. Settings and NotificationPreference fields remain inactive; no email is sent.

New publishers store nullable unique SHA-256 eventKey. Each domain event and recipient fanout commits atomically. On rollback no notification is exposed. Only the unique-key winner fans out; retry never adds recipients or restores deleted entries. Urgent is once per WO, approval once per pending checkout, overdue and PM once per WO/dueAt/kind. Available stock is physical minus quarantine; zero or below minimum produces at most one LOW_STOCK per part per facility date. Daily repetition continues until recovered. All stock balance changes share the low-stock hook; catalogue minimum edits also evaluate it.

## Direct backend helpers (no REST call)

Import from `src/services/notification-events.service.ts`. `createNotification(input, tx?)` creates the notification and eligible active recipients atomically. If an existing transaction is supplied, it joins that transaction; otherwise it opens one with deadlock retries. It returns true when created, false for a duplicate or no eligible recipient. Keep `identity` stable for retries of the same business event, and distinct for separate events. Recipient roles follow the policy above; this helper does not accept arbitrary recipients.

```ts
import { createNotification, notifyWorkOrderReminder } from "./notification-events.service";

await createNotification({
  identity: `inspection-completed:${inspectionId}`,
  kind: "SYSTEM",
  message: "Pemeriksaan selesai.",
});

await prisma.$transaction(async tx => {
  // Domain writes and notification commit or roll back together.
  await createNotification({
    identity: `inspection-completed:${inspectionId}`,
    kind: "SYSTEM",
    message: "Pemeriksaan selesai.",
  }, tx);
});

// Checks current status, releaseAt and dueAt, then creates OVERDUE or PM_DUE.
await prisma.$transaction(tx => notifyWorkOrderReminder(tx, workOrderId));
```

For maintenance use `notifyWorkOrderReminder`, not unconditional creation of an OVERDUE event. It locks/rechecks the WO, excludes completed/cancelled/calibration orders, and deduplicates by WO/dueAt/kind. The scheduled checker reuses this helper. Existing `notifyUrgentOrder`, `notifyCheckoutPending` and `notifyLowStock` helpers remain connected to their respective domain services. Time passing alone cannot invoke a helper: overdue reminders still require a scheduled invocation.

## Scheduled execution

The notification checks are separated by scope:
- `pnpm notifications:check overdue`: every 15 minutes (`*/15 * * * *`). Queries only released, nondeleted OPEN/IN_PROGRESS WO with dueAt before the check time; does not scan parts or produce PM_DUE.
- `pnpm notifications:check daily`: daily at 07:00 Asia/Jakarta (`0 7 * * *` in that timezone, or `0 0 * * *` for a UTC scheduler). Checks released preventive occurrences with a plan and a bounded future dueAt window, rechecks the exact facility day under lock, then checks active parts. Does not create OVERDUE.
- `pnpm notifications:check` (or `all`): manual combined check for compatibility; not the recurring 15-minute job.

Run commands from apps/api with the normal server environment. The daily time is a check schedule, not a notification delivery deadline; PM created/released after the daily run will be considered on the next run or manual check. Urgent/approval and balance/minimum-stock change hooks still execute immediately. This command checks released active WO, generated preventive WO due today and active low-stock parts in batches. It does not generate WO or replay old urgent/approval events. The maintenance generation command must still run separately for PM orders to exist. Concurrent invocation is safe; failed rows produce nonzero exit and structured `notification_check` errors. Monitor exit status and failed counts; retry on the next invocation. The VPS scheduler below runs automatically with the production server.

### VPS runtime

`src/schedulers/notifications.scheduler.ts` uses node-cron. `src/index.ts` starts it only after the HTTP server is listening. The Express app module and Vercel serverless entry do not start timers.

- Production (`NODE_ENV=production`): enabled by default.
- Development/test: disabled by default; opt in with `NOTIFICATIONS_SCHEDULER_ENABLED=true`.
- Set `NOTIFICATIONS_SCHEDULER_ENABLED=false` to disable on any environment.
- Cron timezone is explicitly `Asia/Jakarta`, independent of the VPS operating-system timezone. Reminder eligibility/deduplication still uses the facility timezone.
- Enable on **one** process only. With PM2 cluster/multiple replicas, set the flag false on other workers. In-process overlap protection is not a distributed lock; database event-key deduplication still protects against duplicate notifications.
- `noOverlap` prevents repeated runs of a job. A shared serial queue also prevents overdue and daily scans from running concurrently at 07:00; the daily job waits instead of being discarded.
- No immediate startup scan or automatic replay of missed schedules. If the server was offline at 07:00, an operator can run the daily check manually after recovery; otherwise the next daily trigger is tomorrow. Keep the VPS process supervised.
- SIGTERM/SIGINT stop new scheduled work and HTTP requests, wait for active work, and disconnect Prisma. Shutdown has a 30-second ceiling; configure the process manager to allow at least 35 seconds. Rows already committed remain valid if forced shutdown occurs; reruns remain deduplicated.
- Structured logs: `notification_scheduler_started`, then `notification_check` with scope, checked/created/errors, durationMs and completed/partial_failure/failed status. Failed checks are retried on the next scheduled run, not in a tight loop; daily failures can be retried manually.

Build normally, then start from apps/api with `NODE_ENV=production` in its environment and `pnpm start`. No migration, scheduler HTTP endpoint, or frontend change is needed for this scheduler. Do not run a second OS cron for the same checks while this scheduler is enabled.

Frontend polling every60 seconds (visible authenticated session) and focus refresh only read persisted records. API functions propagate AbortSignal; query keys include account identity; logout cancels/removes that account's notification cache. Page actions invalidate header/detail/list/summary together. Opening the dropdown does not mark unseen items read.

## Deployment and rollback

Models live in prisma/models and are loaded with schema.prisma through prisma.config.ts; do not duplicate models in the root file. Migration `20261016040000_add_notification_event_key` only adds a nullable column and unique index. Run `prisma migrate deploy` then `prisma generate`. Disable the scheduled command first on rollback, then revert publishers/API/consumers, retaining the nullable column and history. Tests run via `npm run test:notifications` in a separately created disposable database, never deleting application records.
