# Design

## Context

See proposal.md for motivation. The core architecture proposal is still unimplemented (0/17 tasks at discovery); reuse it as a prerequisite, not as existing infrastructure. Pilot Plant currently writes nested arrays and base64 photos to IndexedDB. Sub-items and notes use array positions rather than IDs. The live Equipment Sheet has no spare-part editing UI. Maintenance also uses demo state, so database insertion alone would not make accepted work visible there.

Prisma already has PlantUnit, self-related Asset, DamageReport, WorkOrder, AuditLog, and WorkOrderStatusHistory. PlantUnit lacks photo storage, Asset names are not scoped unique, and WorkOrder.damageReportId is currently non-unique. Existing SQL uses utf8mb4_unicode_ci. The UI name limit is 150 and complaint limit is 3000. Existing indexes support unit/level/archive and asset/status/report-time queries.

## Goals / Non-Goals

**Goals:** Implement the current Pilot Plant consumer plus explicit reviewer actions and a reliable read-only handoff to Maintenance. Enforce permissions, relational consistency, and concurrency in the backend. Preserve English identifiers and Indonesian user copy from the baseline.

**Non-Goals:** Full maintenance execution, PM generation/calendar integration, stock consumption, asset movement/archive endpoints, bulk IndexedDB import, multi-instance storage, or general-purpose file hosting. Future execution must honor the lifecycle rules here, but this change does not expose status mutation endpoints.

## Decisions

### Module ownership and role policy

Assets owns hierarchy and report review. Maintenance owns WO creation and reads. The report acceptance orchestrator calls a Maintenance service using the same transaction client; it must not perform an HTTP call or open an independent transaction. Media infrastructure handles validated files. Controllers remain thin and DTOs are explicit.

All four roles read asset data and generated WOs. ADMIN/SUPERVISOR manage master records/photos and review reports. TECHNICIAN additionally submits reports. VIEWER is read-only. The existing schema is single-facility with no unit-based user scope, so no new tenancy model is inferred. Every mutation uses existing cookie-origin safeguards plus role checks. Nested targets must belong to the stated parent and be active. Reject unrecognized/protected input fields rather than passing request objects into persistence calls.

### Schema changes and invariants

- Add PlantUnit.photoKey and Asset.photoKey as nullable server-owned storage references; preserve existing Asset.photoUrl during migration, but do not accept arbitrary URL writes. DTO photoUrl points at authenticated API media routes. Flag existing remote URL values for explicit migration rather than fetching them automatically.
- Add integer version default 0 to PlantUnit and Asset. Name/photo writes conditionally match the version, increment it, and return the new DTO. Failed matches return 409 STATE_CONFLICT; missing/archived targets return 404.
- Keep existing cuid IDs. Generate immutable unit/asset codes from a prefix plus a collision-resistant random token within existing column lengths; retain unique constraints. Do not use MAX(code)+1 or mutable names.
- Add Asset.nameScopeId, non-null and server-derived: unitId for equipment, parentId for sub-items. Enforce @@unique([nameScopeId, name]); existing PlantUnit.name remains unique. This deliberately retains name reservation across archived records for the pilot. Trim names and use existing database collation semantics; frontend checks are advisory. Backfill and detect duplicates before creating the constraint. Validate scope/level/parent/unit together and prohibit hierarchy changes through metadata PATCH.
- Add ACCEPTED to DamageStatus; add reviewedById (named User relation), reviewedAt, reviewNote, and acceptanceInputHash. Add a status/reportedAt/id index for the review queue; retain the asset/status/report-time index.
- Add @unique to nullable WorkOrder.damageReportId and change DamageReport.workOrders to optional workOrder. Non-report WOs retain null. Before migration, detect existing multiple WOs per non-null report and stop for explicit remediation; never delete history to make uniqueness pass.
- Retain original report priority/description; reviewed priority can differ on the WO. WorkOrder.reporterId remains the original reporter, while reviewer identity is stored separately. Initial WO history and audit use the reviewer actor.

An ordinary unique key containing nullable parentId would not protect equipment names. The non-null scope key avoids that ambiguity; MySQL permits multiple nulls in unique indexes (https://dev.mysql.com/doc/refman/8.0/en/create-index.html). Scope redundancy is a deliberate trade-off for simple reads and concurrency-safe uniqueness. Service validation protects its consistency.

### Endpoint contract

All paths below are under /api/v1 and use the baseline response/error contract. List defaults are page=1/pageSize=20, maximum 100. Unknown sort/filter values return 422. Use bounded q (150 characters) and deterministic name/id or reportedAt/id ordering.

| Method and path | Contract |
| --- | --- |
| GET /plant-units | q, condition=ALL/NOTED/CLEAN/UNKNOWN; category summaries |
| POST /plant-units | {name}; 201 with generated identity and Location |
| PATCH /plant-units/:id | {name, version}; 200 |
| GET /plant-units/:id/equipment | q, condition; paginated equipment summaries |
| POST /plant-units/:id/equipment | {name}; derive unit and EQUIPMENT level |
| GET /assets/:id | metadata and condition summary, not unbounded nested history |
| PATCH /assets/:id | {name, version}; no parent/unit/status changes |
| GET /assets/:id/sub-items | equipment parent required; paginated child summaries |
| POST /assets/:id/sub-items | {name}; derive unit/parent/SUB_ITEM |
| GET /pilot-plant/summary | global active-equipment counts, independent of search |
| POST /plant-units/:id/photo; POST /assets/:id/photo | multipart file plus version; 200 updated entity |
| GET /plant-units/:id/photo; GET /assets/:id/photo | authenticated image bytes, 404 if absent |
| DELETE /plant-units/:id/photo; DELETE /assets/:id/photo | version query; 200 updated entity |
| POST /assets/:id/damage-reports | sub-item only; {description, priority}; 201 |
| GET /assets/:id/damage-reports | paginated status-filtered history |
| GET /damage-reports | reviewer queue; status/unitId/priority filters |
| GET /damage-reports/:id | report, reviewer metadata, optional linked WO |
| POST /damage-reports/:id/accept | {priority?, reviewNote?}; 201 first accept, 200 replay |
| POST /damage-reports/:id/dismiss | {reason}; 200 |
| GET /work-orders | bounded read API; source/type/status and pagination filters |
| GET /work-orders/:id | read-only persisted WO detail with asset/report references |

Accept defaults priority to the report value, limits reviewNote to 1000 characters, and creates an unassigned WO with no invented due date. Number is server-generated, unique, and independent of mutable names. releaseAt is now. Assignment and scheduling belong to the later Maintenance execution change. WO list includes only releaseAt <= now; expose safe public projections, not raw relations.

### Atomic review and retry behavior

Normalize effective priority and trimmed reviewNote and hash this canonical acceptance input. Within a transaction, conditionally claim OPEN, persist review fields/hash, create the linked WO via Maintenance, and append initial history/audit. Unique damageReportId is the final duplicate guard. A failed transaction changes nothing.

On a lost race, re-read after the winning transaction commits. If a linked WO exists, its report is ACCEPTED/IN_REPAIR/RESOLVED, and the stored input hash matches, return 200 with the current persisted result without another audit entry. If input differs, report was dismissed, or linked work is CANCELLED, return 409 REPORT_STATE_CONFLICT. Actor is not part of the input hash: two authorized reviewers requesting the same operation get one result while the original reviewer remains attributed. Infrastructure failures are not disguised as state conflicts; retry only positively classified transient database conflicts with a small bounded policy.

Dismiss uses the same OPEN conditional transition and transaction/audit rules. An identical normalized reason replays 200 only for an already DISMISSED report; other states/reasons return 409. Review actions and report submission have per-user rate limits; report submission is not implicitly deduplicated because repeated symptoms can be distinct reports.

```text
Damage OPEN --accept--> ACCEPTED     WO OPEN
       |                                  |
       +--dismiss--> DISMISSED            +--> IN_PROGRESS / report IN_REPAIR
                                          +--> COMPLETED / report RESOLVED
                                          +--> CANCELLED / report ACCEPTED
```

The last three transitions are requirements for future Maintenance execution. Cancellation retains the unique WO link and puts unresolved work back into reviewer attention; a future authorized reopen action must reuse the same WO. Accept is not a hidden reopen action. Full cancellation/reopening UI is outside this change.

### Condition reads and query efficiency

Count active reports in OPEN/ACCEPTED/IN_REPAIR for each equipment and its active sub-items. Prefer NOTED, then UNKNOWN (no active children), then CLEAN. Summaries return counts without full reports. The chart counts equipment, not reports or children. Keep unit totals distinct from filtered matches so the UI can display the equivalent of 'x dari y'. Empty units remain navigable.

Use bounded selects, relation filters/counts, and batch aggregation for page IDs; avoid queries per row or a single giant join that multiplies counts. Verify query plans before adding more indexes. Photo content is never in list JSON.

Replace demo maintenanceDue with nullable nextMaintenanceDueAt from the earliest released, unfinished maintenance WO due date for the equipment or its children. Future unreleased plan projection is not included yet. Do not invent a due-soon threshold: highlight overdue/due-today based on the facility timezone and show future dates neutrally. A due-today value must not disappear through truthiness checks.

### Photo storage and failure handling

Proposed pilot default: one API instance with a configured persistent media directory outside public static roots and source/generated folders. Isolate access behind a storage interface for a later object-store adapter. Use bounded multipart parsing and a maintained image decoder to validate actual format, size, and decoded dimensions; strip metadata/re-encode accepted images. Select dependencies using current documentation during implementation.

Stage the validated file under a random server filename; save the new reference and audit with the entity version check. On database failure, remove the staged file; on success, retire the previous file through safe cleanup. Cleanup only touches resolved paths inside the configured media root and checks references before removal. No client paths, remote fetching, or generic static directory exposure. Authenticated reads use fixed detected Content-Type, nosniff, and private cache policy. Document persistent-volume backup and restoration with the database.

### Consumer migration and bounded Maintenance handoff

Replace useAssetStorage with API-backed loading/mutations; keep browser-local data intact and tell users it has not been migrated. Add stable IDs to sub-items/reports and await confirmed mutation results before success messages. After report/review mutations invalidate relevant asset lists, global summary, report queue, and generated WO reads.

Add a reviewer queue view with Accept/Dismiss and reason input. After acceptance show the generated WO link. In Maintenance provide an explicitly read-only server-backed list/detail for Pilot Plant corrective work; do not merge persisted WOs into editable demo arrays or allow existing local status/parts forms to simulate saving these records. This bounded handoff avoids claiming full Maintenance integration while meeting the need to see accepted work there.

## Risks / Trade-offs

- [Core contract not implemented] -> Deliver baseline first and verify envelope/security compatibility before integration.
- [Existing duplicate names or report/WO links] -> Read-only preflight and explicit data remediation before adding constraints.
- [Files and database cannot commit atomically] -> Stage uploads, preserve old references on failure, and clean unreferenced files conservatively.
- [Single-instance media volume] -> Document operational constraint; object storage is a later adapter, not an unconfigured cloud dependency.
- [Accepted work visible but execution API absent] -> Label Maintenance handoff read-only; track execution as a later feature with required report synchronization.
- [Companion frontend paths outside API write scope] -> Deliver consumer/shared edits from an authorized monorepo workspace.

## Migration Plan

1. Complete the core architecture dependency; inspect live schema/data without assuming the schema-design note proves database state.
2. Back up database/media, check duplicates, and generate a new reviewed migration; do not edit the existing migration. Add/backfill fields and unique keys safely; generate Prisma client.
3. Deploy backend endpoints with persistent storage and run real MySQL concurrency tests in an isolated test database.
4. Deliver shared DTOs and frontend Pilot Plant/reviewer/read-only Maintenance integration together. Keep local IndexedDB untouched.
5. Run permission, image, query-plan, API and UI acceptance checks before release.
6. Roll back UI/routes without destructive data rollback. Preserve ACCEPTED records, one-to-one links, review history, and uploaded files; older code unaware of these fields/statuses must not continue writing them. Restore a compatible version or fix forward.
