# Design

## Context

See proposal.md for motivation. The page keeps 45 dummy notifications in local state, with page size 10, search, date/category/read filters, detail, read-all, delete/undo and filtered CSV. Opening its detail does not mark a row read. The header has a separate static feed, displays three items, clears its local badge on opening, and opens a second history sheet from Lihat semua.

Notification already stores message, details, kind, creation timestamp and optional domain references. NotificationRecipient already provides a composite recipient identity, readAt and deletedAt. NotificationPreference exists but its UI is local and includes an email setting; connecting preferences is outside this proposal. The API uses routes/controllers/services/validators, shared Prisma transactions, authenticated cookies and standard response envelopes.

## Goals / Non-Goals

Goals: reuse recipient state, keep one consistent personal feed, make retries safe, and preserve useful existing page interactions. Non-goals: email delivery, external queues, new notification management pages, historical event replay, calibration removal from unrelated modules, and notification-triggered business approval.

## Decisions

### Review boundaries

Approval recorded 2026-10-16: after reviewing the complete task summary, the user instructed "ya apply tugas ini". This approves F1–F3, D1 and P1–P3 including migration and frontend integration. No second product approval is required for those changes.

This is a proposed implementation contract, not implementation approval. Explicitly confirmed: include the header with only three latest notifications; exclude calibration. The following concrete packages require user approval before applying their affected tasks:

| ID | Proposed change | Reason |
| --- | --- | --- |
| F1 | Replace page dummy state with API/React Query; preserve columns and existing actions; remove simulation footer | Make existing interactions persistent and accurate |
| F2 | Add loading, empty, error/retry and pending-action feedback | Distinguish a failed request from an empty inbox and prevent duplicate clicks |
| F3 | Use real total unread badge; opening dropdown does not mark read; Lihat semua navigates to /notifications, replacing duplicate history sheet; item opens existing page detail | Only three previews cannot acknowledge the whole inbox; one history page avoids duplicate UI |
| D1 | Add nullable unique Notification.eventKey (64-character digest), synchronize Prisma models/schema and ERD, then migrate | Concurrent/retried event generation needs a database uniqueness guarantee |
| P1 | Adopt recipient matrix below | Existing schema does not define business recipients |
| P2 | Adopt event timing and repeat policy below | Avoid notification spam and ambiguous reminder timing |
| P3 | Refresh visible authenticated consumers every 60 seconds and on focus | MVP delivery without realtime infrastructure |

No additional Settings UI or new business-action buttons are proposed. Header account/search/date sections remain outside scope.

### API and ownership

Mount authenticated /notifications routes using existing middleware, origin protection for mutations, DTO serialization, errors and pagination envelopes. Derive user identity from session only. Missing or another recipient's id returns 404; unauthenticated requests return 401. No public notification inbox for kiosk identities.

| Method/path | Behavior |
| --- | --- |
| GET /notifications | Filtered paginated personal visible feed |
| GET /notifications/summary | Total visible unreadCount, independent of page filters |
| GET /notifications/export | All filtered visible rows as CSV, maximum 10,000; explicit validation error above cap, no silent truncation |
| GET /notifications/:id | Owned visible detail |
| PATCH /notifications/:id/read | Set first readAt idempotently |
| PATCH /notifications/read-all | Mark currently visible personal unread rows through server request cutoff; ignore UI filters |
| DELETE /notifications/:id | Set recipient deletedAt; preserve notification and other recipients |
| POST /notifications/:id/restore | Clear recipient deletedAt, preserving readAt |

Register static routes before /:id. Mutations return the existing standard envelope (delete can use established 204 convention). Repeated mutations are safe; serialize overlapping actions per item in the UI. Restore is an explicit action and generation never resurrects a hidden notification.

List/export filters: q (message or reference), category all/warning/system, status all/read/unread, dateFrom/dateTo as YYYY-MM-DD. Validate real dates and order; permit one-sided ranges. Convert inclusive dates to a half-open timestamp interval using SystemSetting.timezone, default Asia/Jakarta, for identical list/export behavior. Pagination follows existing defaults and max100; page requests10, header page1/pageSize3 without filters. Order createdAt descending then id descending, including already-read rows. Return out-of-range pages as empty with pagination metadata; client clamps when deletions shrink results.

DTO supplies id, kind, category, message, detail, reference, timestamp and read/readAt. References derive from existing related codes with a deterministic fallback when absent. Category warning contains OVERDUE, URGENT_WORK_ORDER, LOW_STOCK, APPROVAL; system contains PM_DUE and SYSTEM. Render labels from a single frontend mapping. Exclude CALIBRATION_DUE and notifications linked to calibration work orders consistently from all endpoints; do not heuristically delete history based on message text. Reads never create or acknowledge notifications.

CSV retains the consumer's columns, UTF-8 BOM, proper quoting, formula injection protection, and timezone-consistent timestamps. Detail preserves historical message even if related domain state later changes; this feature does not authorize access to another module's data.

### Generation and recipients (P1/P2)

| Event | Active recipients | Trigger and deduplication identity |
| --- | --- | --- |
| APPROVAL | ADMIN, SUPERVISOR | Checkout enters pending approval; once per checkout |
| URGENT_WORK_ORDER | ADMIN, SUPERVISOR, TECHNICIAN | Non-calibration WO created urgent or first becomes urgent; once per WO |
| OVERDUE | ADMIN, SUPERVISOR, TECHNICIAN | Released active WO passes due timestamp; once per WO and due timestamp |
| PM_DUE | ADMIN, SUPERVISOR, TECHNICIAN | Generated, released preventive WO is due on the local date; once per WO and due timestamp |
| LOW_STOCK | ADMIN, SUPERVISOR | Available stock is zero or below minimum; at most once per part per local date |

No assignment-dependent notification. VIEWER receives no automatic events under this proposed matrix but may read existing owned notifications. Public checkout requesterName is not an account; do not invent an inbox recipient. Auto-approved supervisor/admin checkout produces no approval-needed notification. Notify on real released PM orders, not projected calendar entries. Terminal/cancelled WO and calibration WO are ineligible for reminders. PM_DUE applies before the due instant on that date; after it, OVERDUE applies, so the same check cannot emit both for one WO. No retrospective urgent/approval replay on deployment.

Low-stock recurrence is explicitly daily while the condition persists, not once per poll or once per recovery episode. This bounded MVP policy avoids adding a separate stock-episode table. Zero available stock qualifies even if minimum is zero; quarantined stock is unavailable. Cover checkout approval's direct balance mutation as well as ledger posting, quarantine, releases, reversals and minimum-stock edits. Scheduled checking catches remaining low stock after deployment. Replenishment does not delete historical alerts.

Implement one reusable publisher that receives the current transaction client. Persist in-app notification plus recipient rows in the domain transaction, without network delivery. This makes committed in-app events durable and invisible before commit; external delivery, if introduced later, remains after commit as required by the architecture. A notification persistence failure rolls back its domain transaction. The alternative of non-durable after-commit callbacks risks losing events on process crashes.

Use a canonical event identity hashed to eventKey; unique conflicts are treated as a duplicate only for that exact key. Reuse existing transaction retry conventions and ensure recipients are complete atomically. Resolve active recipients at generation time; no retroactive fanout on role changes. If no eligible recipients exist, skip creation and retry only when the underlying event is checked again. Do not add SYSTEM broadcast creation endpoints. Preferences are not activated in this change, including the dormant urgentWorkOrderInApp field; this limitation must be documented.

Provide an explicit repeatable scheduled-check CLI for due/overdue and low-stock checks, with structured counts/errors. Deployment documents a one-minute invocation schedule; do not silently install a scheduler. Concurrent runs and retried domain requests must produce one notification per identity. Process bounded batches and recheck terminal state within the transaction.

### Consumers

Use notifications.service.ts as the HTTP/DTO boundary following service-http.ts and existing service conventions. React Query hooks handle lists, detail, summary and mutations. Include authenticated user id plus normalized filters/page in keys, use request AbortSignal, and invalidate notification queries after mutations so header and page agree. Cancel and remove only notification cache on logout/account change. Poll only while authenticated and visible; never reuse old user's placeholder data.

Keep the existing page's detail/read behavior and all-filtered read-all/export semantics. Header shows min(3,total) newest visible notifications and true relative times, with no synthetic Hari Ini/Kemarin classification. Opening the menu is read-only. Under F3, item navigation selects its existing detail on /notifications, while Lihat semua opens the unfiltered page. Failed requests display retry feedback rather than fictitious counts. No extra CTA for restock, approval, or work-order assignment.

## Risks / Trade-offs

- Daily low-stock reminders can repeat while unresolved → explicit P2 review; unique daily key bounds repeats.
- Notifications increase domain transaction work → bounded recipient sets, batch writes and integration rollback tests.
- Scheduled job not running delays reminders → deployment checklist and observable check summary; polling does not generate events.
- Legacy calibration messages without structured references cannot be reliably classified → exclude known kind/WO links; no destructive keyword-based cleanup.
- Role changes do not retract historical inbox ownership → current recipients reflect delivery-time eligibility; all detail actions still enforce module permissions.
- Nullable eventKey leaves legacy rows undeduplicated → only new publishers use keys; no backfill or historical deletion.

## Migration Plan

Obtain F1–F3/D1/P1–P3 approval. Update prisma/models before regenerating the assembled schema; update schema-design.md. Create and inspect the additive migration with Prisma CLI, then migrate and generate client. Deploy API and publishers, frontend, and explicitly configure the scheduled command. Verify authenticated isolation and real event delivery before enabling the schedule. Rollback disables schedule/publishers and reverts consumers/API; retain the nullable column and historical records rather than deleting user state.
