From 44b0ef0168b3b9a0a5a7796492810ff30910ff6b Mon Sep 17 00:00:00 2001 From: shancheas Date: Tue, 25 Aug 2026 19:14:39 +0700 Subject: [PATCH] feat: introduce comprehensive API documentation and RBAC guidelines - Added a new `api.md` file detailing the TrackGo HTTP API, including agent rules, authentication mechanisms, and global HTTP contracts. - Established a new RBAC (Role-Based Access Control) framework in `web-rbac.mdc` to ensure all product modules in `apps/web` are gated by permissions from `GET /auth/me`. - Updated security and web module architecture rules to incorporate RBAC requirements, ensuring consistent application of permissions across modules. This commit enhances the project's API clarity and security by providing a structured approach to user permissions and interactions. --- .cursor/rules/security.mdc | 1 + .cursor/rules/web-module-architecture.mdc | 1 + .cursor/rules/web-rbac.mdc | 42 + api.md | 966 ++++++++++++++++++++++ 4 files changed, 1010 insertions(+) create mode 100644 .cursor/rules/web-rbac.mdc create mode 100644 api.md diff --git a/.cursor/rules/security.mdc b/.cursor/rules/security.mdc index e276ee5..4923a30 100644 --- a/.cursor/rules/security.mdc +++ b/.cursor/rules/security.mdc @@ -13,6 +13,7 @@ Before ANY commit: - [ ] All user inputs validated (Zod + `@repo/ui/validators`) - [ ] XSS prevention — React text nodes by default; never unsanitized `dangerouslySetInnerHTML` - [ ] Auth tokens only via `src/core/lib/auth.helper` and the shared `apiClient` interceptors +- [ ] Product modules in `apps/web` wire RBAC (`moduleKey` + menu `moduleKey`); see `web-rbac.mdc` - [ ] No secrets in client bundles; env files only under `apps/*/.env*` - [ ] Error messages shown to users do not leak tokens or stack traces diff --git a/.cursor/rules/web-module-architecture.mdc b/.cursor/rules/web-module-architecture.mdc index 600d2a1..709745f 100644 --- a/.cursor/rules/web-module-architecture.mdc +++ b/.cursor/rules/web-module-architecture.mdc @@ -52,6 +52,7 @@ Copy `example/full-page` — do not invent a third layout style. 3. **Domain factory** — singleton service via `apiClient` from `src/core/lib/api-client` (never raw axios). 4. **i18n** — `useEnterpriseModuleTranslationContext()`; module keys unprefixed; shared via `common:` / `nav:`. 5. **Navigation** — `useEnterpriseModuleNavigationContext()` helpers, not ad-hoc paths. +6. **RBAC** — `moduleKey` must match an `api.md` privilege-keys `code`; the menu leaf uses the same `moduleKey`. See `web-rbac.mdc`. ## Layer rules diff --git a/.cursor/rules/web-rbac.mdc b/.cursor/rules/web-rbac.mdc new file mode 100644 index 0000000..d2b64de --- /dev/null +++ b/.cursor/rules/web-rbac.mdc @@ -0,0 +1,42 @@ +--- +description: RBAC is required for every apps/web product module (menu, moduleKey, action flags) +globs: apps/web/src/apps/main/**/*.{ts,tsx} +alwaysApply: false +--- + +# Web module RBAC + +Every authenticated product module in `apps/web` must be gated by `GET /auth/me` permissions. Copy Privileges (`system/privileges`) — do not invent a second RBAC path. + +Catalog keys live in `api.md` §4 (`PRIVILEGES`, `CONFIGURATION.BRANCH`, `SALES.ORDER`, …). `isSuperadmin` bypasses the matrix (adapter returns `defaultPrivileges`). + +## Required wiring (do all four) + +1. **`moduleKey`** on `ModuleConfigEntity` equals the catalog `code` (e.g. `CONFIGURATION.BRANCH`). +2. **Menu leaf** in `layouts/data/menu.data.ts` sets the same `moduleKey`. `filterMenuByViewPrivilege` hides the item when `ALLOW_VIEW` is false. +3. **Routes** wrap in `EnterpriseModuleProvider` so missing `ALLOW_VIEW` shows forbidden (no all-true flash). +4. **Do not** re-check create/edit/delete in page JSX. Foundations already hide actions from `PrivilegeEntity`. + +```ts +// BAD — custom hide/show, or menu without moduleKey +if (!user.permissions.BRANCHES?.create) return null; +{ key: 'branches', path: '/app/system/branches/index' } + +// GOOD +export const branchesModuleConfig = { moduleKey: 'CONFIGURATION.BRANCH', /* ... */ }; +{ key: 'system-branches', path: '/app/system/branches/index', moduleKey: 'CONFIGURATION.BRANCH' } +``` + +## Flag map (`mapUserPrivileges`) + +| API flag | UI | +|---|---| +| `view` | `ALLOW_VIEW` (menu + module chrome) | +| `create` | `ALLOW_CREATE` (create + duplicate) | +| `update` | `ALLOW_EDIT` + `ALLOW_ACTIVATE` + `ALLOW_DEACTIVATE` | +| `delete` | `ALLOW_DELETE` | +| `import` | `ALLOW_IMPORT` | + +Missing flag → `false`. Cycles/plans: key follows `purpose` (`SALES.CYCLE` / `LOGISTICS.PLAN`), not a generic `CYCLES` key. + +Reference: [apps/web/src/core/lib/map-user-privileges.ts](apps/web/src/core/lib/map-user-privileges.ts), [filter-menu-by-view-privilege.ts](apps/web/src/core/lib/filter-menu-by-view-privilege.ts). diff --git a/api.md b/api.md new file mode 100644 index 0000000..92d0964 --- /dev/null +++ b/api.md @@ -0,0 +1,966 @@ +# TrackGo HTTP API — frontend reference + +Static contract for a frontend agent or UI. Source of truth is this backend’s controllers and DTOs. Live OpenAPI (when the server is running and Swagger is enabled): `GET /docs` (UI) and `GET /docs-json`. + +Base URL: `http://localhost:{PORT}` (default **3000**). There is **no** global path prefix. JSON keys are **camelCase**. IDs are **UUID v4**. + +--- + +## 1. Agent rules (read first) + +1. Send `Authorization: Bearer ` on every route except `GET /` and `POST /auth/register|login|refresh|revoke`. +2. List endpoints return `{ data, meta }`. Detail, create, update, status, import, and bulk return the **bare** DTO or a small object. `DELETE` is **204** with an empty body (except nested plan destinations, which return the plan). +3. Request dates as ISO datetime or `YYYY-MM-DD`. Response timestamps are **unix milliseconds** (`number`). +4. Phones are E.164 (`+6281234567890`). Money, quantities, and prices are **decimal strings** (for example `"12500.0000"`), never floats. +5. Never send `status` on `PATCH /:id`. Use `PATCH /:id/status` or `POST /bulk-status`. +6. Unknown JSON fields are rejected (`400`). Do not send snake_case aliases. +7. Gate UI with `GET /auth/me` → `isSuperadmin` or `permissions[KEY][action]`. Superadmin bypasses the matrix. +8. Cycles and plans are purpose-scoped (`sales` | `logistics`) and use **different privilege keys**. Pass `purpose` on writes (body) and preferably on lists (query). +9. Cycles and plans `DELETE` / `bulk-delete` **archive** (`status: "archived"`); they do not hard-delete. +10. Default new-record status is `draft` when omitted. + +--- + +## 2. Auth + +### Tokens + +| Token | Type | Default lifetime | Transport | +| ----- | ---- | ---------------- | --------- | +| Access | JWT HS256 | `15m` (`JWT_ACCESS_EXPIRES_IN`) | `Authorization: Bearer …` | +| Refresh | Opaque 64-char string | 7 days (`REFRESH_TOKEN_EXPIRES_IN_MS`) | JSON body `refreshToken` | + +Refresh **rotates**: each successful `POST /auth/refresh` returns a new pair; the old refresh token is invalid. + +Rate limits: register/login **5 / 60s**, refresh/revoke **10 / 60s**, everything else **100 / 60s**. Exceeded → `429`. + +### Endpoints + +#### `POST /auth/register` — public — `201` + +```json +{ "username": "alice", "password": "password123" } +``` + +- `username`: 3–32 chars, `^[a-zA-Z0-9_]+$`, stored lowercased +- `password`: 8–72 chars +- Conflict: `409` `{ "statusCode": 409, "message": "Username already registered", "error": "Conflict" }` + +Response: + +```json +{ "accessToken": "eyJ…", "refreshToken": "aaaa…" } +``` + +New users have **no privilege** (`privilegeId` null). They cannot call protected CRUD until an admin assigns a privilege (`PATCH /users/:id/privilege`) or they are promoted to superadmin in the database. + +#### `POST /auth/login` — public — `200` + +Same body and response as register. Invalid credentials → `401`. + +#### `POST /auth/refresh` — public — `200` + +```json +{ "refreshToken": "" } +``` + +`refreshToken` min length 32. Invalid → `401`. + +#### `POST /auth/revoke` — public — `204` + +Same body. Idempotent: already-invalid tokens still return 204. + +#### `GET /auth/me` — bearer — `200` + +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "username": "alice", + "isSuperadmin": false, + "privilege": { "id": "…", "name": "Sales Staff", "code": "SALES_STAFF" }, + "permissions": { + "CONFIGURATION.BRANCH": { + "view": true, + "create": true, + "update": true, + "delete": false, + "import": false + } + } +} +``` + +If the user has no privilege, `privilege` is `null` and `permissions` is `{}`. + +Use `permissions` to hide buttons. Missing key or `false` → treat as denied (API returns `403 Insufficient privilege`). + +--- + +## 3. Global HTTP contract + +### Headers + +``` +Content-Type: application/json +Authorization: Bearer +``` + +CSV import uses `multipart/form-data` with field name **`file`** (max 1 MiB). + +### Pagination (list `GET /resource` only) + +Query (all optional): + +| Param | Rules | Default | +| ----- | ----- | ------- | +| `page` | integer ≥ 1 | `1` | +| `limit` | 1–**200** | `10` | +| `offset` | integer ≥ 0 | — | + +If both `page` and `offset` are sent, **`page` wins**. `offset` maps to `page = floor(offset / limit) + 1`. + +Public response: + +```json +{ + "data": [ /* items */ ], + "meta": { + "currentPage": 1, + "itemCount": 10, + "itemsPerPage": 10, + "totalItems": 42, + "totalPages": 5 + } +} +``` + +`search` is case-insensitive and AND-combined with other filters. Resource-specific filters are documented per module. + +### Non-list responses + +Unwrapped resource object, or: + +| Operation | Status | Body | +| --------- | ------ | ---- | +| Create | `201` | resource DTO (detail shape) | +| Update / status | `200` | resource DTO | +| Delete | `204` | empty | +| Bulk delete | `200` | `{ "deleted": number }` | +| Bulk status | `200` | `{ "updated": number }` | +| Import | `200` | `{ "imported": number }` | +| Generate plans | `200` | `{ "created": number, "skipped": number }` | + +### Errors (NestJS default) + +```json +{ + "statusCode": 400, + "message": "status cannot be updated via PATCH", + "error": "Bad Request" +} +``` + +`message` is a string or an array of validation strings. + +| Status | Typical cause | +| ------ | ------------- | +| `400` | Validation, extra fields, invalid VO (phone/date/status), `status` on PATCH | +| `401` | Missing/expired/revoked JWT, bad credentials, invalid refresh | +| `403` | `Insufficient privilege` | +| `404` | `{Resource} not found` (for example `Branch not found`) | +| `409` | Unique conflict (username, code, cycle/plan already exists) | +| `429` | Rate limit | + +CSV batch failure: + +```json +{ + "statusCode": 400, + "message": "CSV validation failed", + "errors": ["row 3: Invalid phone number"] +} +``` + +VO messages **do not echo raw input**: `"Invalid phone number"`, `"Invalid date time"`, `"Invalid status"`. + +### Status + +Core (configuration, privileges, cycles, plans, settings): `draft` | `active` | `archived`. Omit on create → `draft`. + +Sales statuses (use **only** these on that resource): + +| Resource | Allowed | +| -------- | ------- | +| Sales request | `draft`, `pending`, `approved`, `rejected` | +| Sales order / packing slip | `draft`, `processed`, `completed`, `cancelled` | +| Sales invoice | `draft`, `processed`, `partial`, `completed`, `cancelled` | +| Sales payment | `draft`, `pending`, `approved`, `rejected` | + +### Dates + +- **In:** ISO datetime (`2026-08-24T10:00:00+07:00`) or calendar `YYYY-MM-DD`. Naive ISO (no `Z`/offset) is interpreted in `DEFAULT_TIMEZONE` (default `GMT+7`). +- **Out:** unix **milliseconds** UTC for `date`, `createdAt`, `updatedAt`, `cycleStartDate`, plan `date`. +- Display timezone is GMT+7 unless the backend env says otherwise. Persist/compare using the numeric ms, not a formatted string. + +### Phones + +E.164 compact. Invalid → `400 Invalid phone number`. + +### Shared write bodies + +```json +{ "status": "active" } +``` + +```json +{ "ids": ["uuid", "uuid"] } +``` + +```json +{ "ids": ["uuid"], "status": "archived" } +``` + +`ids` must be a non-empty UUID array. + +--- + +## 4. Privileges + +Actions: `view` | `create` | `update` | `delete` | `import`. + +HTTP mapping: + +| Handler | Action | +| ------- | ------ | +| `GET` list / detail | `view` | +| `POST /` create, `POST /plans/generate` | `create` | +| `PATCH /:id`, `PATCH /:id/status`, `POST /bulk-status`, nested customer contacts, plan destinations | `update` | +| `DELETE /:id`, `POST /bulk-delete` | `delete` | +| `POST /import` | `import` | + +Catalog (`GET /privilege-keys`, needs `PRIVILEGES` `view`): + +| code | label | +| ---- | ----- | +| `PRIVILEGES` | Privileges | +| `USERS` | Users | +| `CONFIGURATION.DIVISION` | Divisions | +| `CONFIGURATION.BRANCH` | Branches | +| `CONFIGURATION.CUSTOMER` | Customers | +| `CONFIGURATION.EMPLOYEE` | Employees | +| `CONFIGURATION.PRODUCT` | Products | +| `SALES.REQUEST` | Sales requests | +| `SALES.ORDER` | Sales orders | +| `SALES.PACKING_SLIP` | Packing slips | +| `SALES.INVOICE` | Sales invoices | +| `SALES.PAYMENT` | Sales payments | +| `CONFIGURATION.SETTING` | Company settings | +| `SALES.CYCLE` | Sales cycles | +| `SALES.PLAN` | Sales plans | +| `LOGISTICS.CYCLE` | Logistics cycles | +| `LOGISTICS.PLAN` | Logistics plans | + +### Field purpose + +Cycles and plans do **not** use a single key. Privilege is resolved from `purpose`: + +| purpose | cycle key | plan key | +| ------- | --------- | -------- | +| `sales` | `SALES.CYCLE` | `SALES.PLAN` | +| `logistics` | `LOGISTICS.CYCLE` | `LOGISTICS.PLAN` | + +`purpose` is read from **body** (writes) or **query** (lists). If omitted, the user may proceed if they have the action on **either** purpose; list results are filtered to purposes they can view. Superadmin bypasses. + +Sales plans may attach `invoiceIds` only. Logistics plans may attach `packingSlipIds` only. + +--- + +## 5. Standard CRUD (most resources) + +Unless a section says otherwise, each resource below implements: + +| Method | Path | Status | Notes | +| ------ | ---- | ------ | ----- | +| `GET` | `/{resource}` | `200` | Paginated `{ data, meta }` | +| `GET` | `/{resource}/:id` | `200` | Detail (may include nested arrays list omits) | +| `POST` | `/{resource}` | `201` | Create | +| `PATCH` | `/{resource}/:id` | `200` | Update — **no** `status` | +| `PATCH` | `/{resource}/:id/status` | `200` | `{ status }` | +| `DELETE` | `/{resource}/:id` | `204` | Hard delete (cycles/plans: archive) | +| `POST` | `/{resource}/bulk-delete` | `200` | `{ ids }` → `{ deleted }` | +| `POST` | `/{resource}/bulk-status` | `200` | `{ ids, status }` → `{ updated }` | +| `POST` | `/{resource}/import` | `200` | multipart `file` → `{ imported }` | + +Audit fields on primary DTOs: `createdAt`, `updatedAt` (unix ms), `createdBy`, `updatedBy` (user UUID). + +Name/code patterns used by configuration: + +- **Code:** `^[A-Za-z0-9_]+$` (no spaces). Max 16 except products (32). +- **Name** (division, branch, customer, employee): letters with single spaces, max 64. +- **Product name:** letters, digits, `+ - . / ( )`, max 128. + +--- + +## 6. Health + +`GET /` — public — `"Hello World!"` (plain string). + +--- + +## 7. Users + +Privilege: `USERS` `update` only (no list/create API). + +### `PATCH /users/:id/privilege` — `200` + +```json +{ "privilegeId": "uuid-or-null" } +``` + +`null` clears the assignment. Assigned privilege must be **active**. Response: `{ id, username, privilegeId }`. + +--- + +## 8. Privilege keys + +Privilege: `PRIVILEGES` `view`. + +### `GET /privilege-keys` + +Query: pagination + `search`. + +Item: + +```json +{ "id": "uuid", "code": "PRIVILEGES", "label": "Privileges", "sortOrder": 1 } +``` + +Use this catalog when building the privilege-matrix editor. + +--- + +## 9. Privileges + +Privilege key: `PRIVILEGES`. Standard CRUD + import. + +List filters: `name`, `code`, `status`, `search` (name/code). + +**Create** `POST /privileges`: + +```json +{ + "name": "Sales Staff", + "code": "SALES_STAFF", + "status": "draft", + "details": [ + { "privilegeKeyId": "uuid", "action": "view", "value": true } + ] +} +``` + +`name` max 120, `code` max 64. `details` optional; omitted cells are treated as denied. + +**Update** `PATCH /privileges/:id`: `name?`, `code?`, `details?` (replaces matrix when sent). + +**List item** omits `details`. **Detail / create / update** include: + +```json +{ + "id": "uuid", + "name": "Sales Staff", + "code": "SALES_STAFF", + "status": "active", + "details": [ + { + "id": "uuid", + "privilegeKeyId": "uuid", + "keyCode": "SALES.INVOICE", + "keyLabel": "Sales invoices", + "sortOrder": 11, + "action": "view", + "value": true + } + ], + "createdAt": 1710000000000, + "updatedAt": 1710000000000, + "createdBy": "uuid", + "updatedBy": "uuid" +} +``` + +Delete of a privilege assigned to users → `409`. + +CSV headers: `name`, `code` (optional `status`). + +--- + +## 10. Divisions + +Key: `CONFIGURATION.DIVISION`. Standard CRUD + import. + +**Create:** `{ name, code, status? }` + +List filters: `name`, `code`, `status`, `search`. + +DTO: `{ id, name, code, status, createdAt, updatedAt, createdBy, updatedBy }` + +CSV: `name`, `code` (optional `status`). + +--- + +## 11. Branches + +Key: `CONFIGURATION.BRANCH`. Standard CRUD + import. + +**Create (required):** `code`, `name`, `phone`, `address`, `workingDaysStart`, `workingDaysEnd`, `workingHoursStart`, `workingHoursEnd` + +**Optional:** `latitude` (−90…90), `longitude` (−180…180), `nfcId` (max 64), `divisionId`, `status` + +Weekdays: `monday` … `sunday`. Hours: `HH:mm` 24-hour (`08:00`). Address max 255. + +**Update** may set `latitude`, `longitude`, `nfcId`, `divisionId` to `null` to clear. + +List filters: `code`, `name`, `phone`, `address`, `divisionId`, `nfcId`, `status`, `workingDaysStart`, `workingDaysEnd`, `workingHoursStart`, `workingHoursEnd`, `search` (code/name/address). + +Response: + +```json +{ + "id": "uuid", + "code": "JKT_01", + "name": "Jakarta Pusat", + "phone": "+6281234567890", + "address": "Jl Sudirman No 1", + "latitude": -6.2, + "longitude": 106.8, + "workingDaysStart": "monday", + "workingDaysEnd": "friday", + "workingHoursStart": "08:00", + "workingHoursEnd": "17:00", + "nfcId": "NFC-001", + "divisionId": "uuid", + "status": "active", + "createdAt": 1710000000000, + "updatedAt": 1710000000000, + "createdBy": "uuid", + "updatedBy": "uuid" +} +``` + +`latitude` / `longitude` / `nfcId` / `divisionId` may be `null`. + +CSV required: `code`, `name`, `phone`, `address`, `workingDaysStart`, `workingDaysEnd`, `workingHoursStart`, `workingHoursEnd`. Optional: `latitude`, `longitude`, `nfcId`, `divisionId`, `status`. + +--- + +## 12. Customers + +Key: `CONFIGURATION.CUSTOMER`. Standard CRUD + import, plus nested contacts. + +**Create (required):** `code`, `name`, `phone`, `address` + +**Optional:** `latitude`, `longitude`, `nfcId`, `status`, `contacts[]` + +Contact create: `name` required; `jobTitle?`, `phone?`, `mobilePhone?`, `notes?` (notes max 255). + +**List** omits `contacts`. **Detail / create / update / contact mutations** include `contacts`. + +### Nested contacts (privilege = customer **update**) + +| Method | Path | Status | Body | Response | +| ------ | ---- | ------ | ---- | -------- | +| `POST` | `/customers/:id/contacts` | `200` | create contact | full `CustomerDto` | +| `PATCH` | `/customers/:id/contacts/:contactId` | `200` | partial contact | full `CustomerDto` | +| `DELETE` | `/customers/:id/contacts/:contactId` | `204` | — | empty | + +List filters: `code`, `name`, `phone`, `address`, `nfcId`, `status`, `search` (code/name/address). + +CSV: `code`, `name`, `phone`, `address` (optional lat/long/nfc/status). **No contacts in CSV.** + +--- + +## 13. Employees + +Key: `CONFIGURATION.EMPLOYEE`. Standard CRUD + import. + +**Create:** `{ code, name, phone, position, status? }` + +`position`: `sales` | `driver` | `crew` + +List filters: `code`, `name`, `phone`, `position`, `status`, `search` (code/name). + +DTO: `{ id, code, name, phone, position, status, createdAt, updatedAt, createdBy, updatedBy }` + +CSV: `code`, `name`, `phone`, `position`. + +--- + +## 14. Products + +Key: `CONFIGURATION.PRODUCT`. Standard CRUD + import. + +**Create:** `{ code, name, unit?, price?, brand?, status? }` + +`price` is a decimal **string** (`"12500.0000"`). `unit` letters/numbers, max 16. `brand` max 64. + +List filters: `code`, `name`, `unit`, `brand`, `status`, `search` (code/name). + +DTO: `{ id, code, name, unit, price, brand, status, …audit }` with `unit`/`price`/`brand` nullable. + +CSV: `code`, `name` (optional `unit`, `price`, `brand`, `status`). + +--- + +## 15. Sales documents (shared) + +Sales codes (optional on create; auto-generated if omitted): max 32, `^[A-Za-z0-9][A-Za-z0-9_-]*$`. + +Line input: + +```json +{ "productId": "uuid", "quantity": "2.0000", "price": "12500.0000" } +``` + +`price` optional on input; response always includes `price` as a decimal string. + +Image input (requests, orders, payments): + +```json +{ "url": "https://cdn.example.com/a.png", "description": "optional" } +``` + +`url` max 2048, `description` max 255. + +**List items omit** `products` / `images` / payment `invoices`. **GET :id, POST, PATCH, PATCH status** include them with generated `id` on each nested row. + +Quantity/price/amount/balance are **strings**. + +--- + +## 16. Sales requests + +Key: `SALES.REQUEST`. Standard CRUD + import. + +**Create (required):** `date`, `salesPersonId`, `branchId`, `divisionId`, `customerId`, `address`, `products[]` + +**Optional:** `code`, `latitude`, `longitude`, `notes` (max 1024), `images[]`, `status` + +List filters: `code`, `status`, `customerId`, `salesPersonId`, `branchId`, `divisionId`, `search` + +List DTO: + +```json +{ + "id": "uuid", + "code": "SR-20260824-0001", + "date": 1756000000000, + "salesPersonId": "uuid", + "branchId": "uuid", + "divisionId": "uuid", + "customerId": "uuid", + "address": "Jl Sudirman 1", + "latitude": null, + "longitude": null, + "notes": null, + "status": "draft", + "createdAt": 1756000000000, + "updatedAt": 1756000000000, + "createdBy": "uuid", + "updatedBy": "uuid" +} +``` + +Detail adds: + +```json +{ + "products": [ + { "id": "uuid", "productId": "uuid", "quantity": "2.0000", "price": "12500.0000" } + ], + "images": [ + { "id": "uuid", "url": "https://…", "description": null } + ] +} +``` + +Import CSV required: `date`, `salesPersonId`, `branchId`, `divisionId`, `customerId`, `address`. Products/images empty on import. + +--- + +## 17. Sales orders + +Key: `SALES.ORDER`. Same shape as requests, plus optional `salesRequestId` on create (copies missing fields from the request). + +Response includes `salesRequestId` (`string | null`). Statuses: `draft` | `processed` | `completed` | `cancelled`. + +List filters: `code`, `status`, `customerId`, `salesPersonId`, `branchId`, `divisionId`, `search`. + +--- + +## 18. Packing slips + +Key: `SALES.PACKING_SLIP`. Statuses: `draft` | `processed` | `completed` | `cancelled`. + +**Create fields are optional in JSON.** If `salesOrderId` is set, missing `date` / `customerId` / `address` / `products` are copied from the order. Service still requires a resolved date, customer, and address. + +Optional: `code`, `salesOrderNumber`, `latitude`, `longitude`, `notes`, `status`. + +List filters: `code`, `status`, `customerId`, `salesOrderId`, `search`. + +DTO: `{ id, code, salesOrderId, salesOrderNumber, date, customerId, address, latitude, longitude, notes, status, …audit }` + +Detail adds `products[]`. + +CSV required: `date`, `customerId`, `address`. + +--- + +## 19. Sales invoices + +Key: `SALES.INVOICE`. Statuses: `draft` | `processed` | `partial` | `completed` | `cancelled`. + +**Create fields optional.** May derive from `salesOrderId` and/or `packingSlipId`. + +List filters: `code`, `status`, `customerId`, `salesPersonId`, `branchId`, `divisionId`, `salesOrderId`, `packingSlipId`, `search`. + +DTO extras vs order: `salesOrderCode`, `packingSlipId`, `packingSlipCode`, `balance` (decimal string). Detail adds `products[]`. + +CSV required: `date`, `salesPersonId`, `branchId`, `divisionId`, `customerId`. + +--- + +## 20. Sales payments + +Key: `SALES.PAYMENT`. Statuses: `draft` | `pending` | `approved` | `rejected`. + +**Create (required):** `date`, `invoices` (non-empty allocations) + +```json +{ + "date": "2026-08-24T10:00:00+07:00", + "invoices": [{ "invoiceId": "uuid", "amount": "10000.0000" }], + "notes": null, + "images": [], + "status": "draft" +} +``` + +List filters: `code`, `status`, `search`. + +List DTO: `{ id, code, date, notes, status, …audit }` + +Detail adds `invoices[{ id, invoiceId, amount }]` and `images[{ id, url, description }]`. + +CSV required: `date` (allocations empty on import). + +--- + +## 21. Company settings + +Key: `CONFIGURATION.SETTING`. Singleton — no list/CRUD. + +| Method | Path | Action | Notes | +| ------ | ---- | ------ | ----- | +| `GET` | `/settings` | view | `404` `{ "message": "Settings not configured" }` if never patched | +| `PATCH` | `/settings` | update | upserts | + +**Patch body:** `{ "cycleStartDate": "2026-01-05" }` (`YYYY-MM-DD` only). + +Response: + +```json +{ + "id": "uuid", + "cycleStartDate": 1767546000000, + "status": "active", + "createdAt": 1710000000000, + "updatedAt": 1710000000000, + "createdBy": "uuid", + "updatedBy": "uuid" +} +``` + +`cycleStartDate` is unix ms for **start of that calendar day** in `DEFAULT_TIMEZONE`. Plan generate rejects ranges that start before this date. + +--- + +## 22. Cycles + +Privilege: `RequireFieldPrivilege('cycle', action)` → `SALES.CYCLE` or `LOGISTICS.CYCLE`. + +No hard delete: `DELETE` / `bulk-delete` archive. **Has CSV import.** Standard list/detail/create/update/status otherwise. + +**Create:** + +```json +{ + "employeeId": "uuid", + "purpose": "sales", + "cycleNumber": 1, + "weekdays": { + "monday": { + "startBranchId": "uuid", + "endBranchId": "uuid", + "customerIds": ["uuid"] + } + }, + "status": "draft" +} +``` + +- `purpose`: `sales` | `logistics` +- `cycleNumber`: integer ≥ 1 +- `weekdays`: object keyed by `monday`…`sunday`. **Omitted days are days off.** Any present day must include `startBranchId`, `endBranchId`, and a **non-empty** `customerIds`. Incomplete day → `400 Weekday must be complete`. +- Unique per `(employeeId, purpose, cycleNumber)` → `409 Cycle already exists for this employee` +- All referenced branches/customers must have usable lat/lng or route build fails (`400 Weekday route is incomplete`) + +**Response `weekdays` is an array**, not an object: + +```json +{ + "id": "uuid", + "employeeId": "uuid", + "purpose": "sales", + "cycleNumber": 1, + "weekdays": [ + { + "id": "uuid", + "weekday": "monday", + "startBranchId": "uuid", + "endBranchId": "uuid", + "routeGeometry": { + "type": "LineString", + "coordinates": [[106.8456, -6.2088], [107.0, -6.3]] + }, + "destinations": [ + { "id": "uuid", "customerId": "uuid", "sortOrder": 0 } + ] + } + ], + "status": "draft", + "createdAt": 1710000000000, + "updatedAt": 1710000000000, + "createdBy": "uuid", + "updatedBy": "uuid" +} +``` + +`routeGeometry.coordinates` are GeoJSON `[longitude, latitude]` pairs. + +List filters: `employeeId`, `purpose`, `cycleNumber`, `status`, `search`. Without `purpose`, results are limited to purposes the caller can view. + +CSV required: `employeeCode`, `purpose`, `cycleNumber`, `weekday`, `customerCodes`, `startBranchCode`, `endBranchCode`. Multiple rows with the same employee/purpose/cycleNumber merge weekdays. + +--- + +## 23. Plans + +Privilege: `RequireFieldPrivilege('plan', action)` → `SALES.PLAN` or `LOGISTICS.PLAN`. + +**No CSV import.** Delete archives. Extra routes: generate, add/remove destinations. + +| Method | Path | Action | Status | Body / response | +| ------ | ---- | ------ | ------ | --------------- | +| `GET` | `/plans` | view | 200 | paginated | +| `GET` | `/plans/:id` | view | 200 | `PlanDto` | +| `POST` | `/plans/generate` | create | 200 | `{ employeeId, purpose, from, to }` → `{ created, skipped }` | +| `POST` | `/plans` | create | 201 | see below | +| `PATCH` | `/plans/:id` | update | 200 | no status | +| `PATCH` | `/plans/:id/status` | update | 200 | `{ status }` | +| `POST` | `/plans/:id/destinations` | update | 200 | `{ customerId, afterDestinationId? }` → `PlanDto` | +| `DELETE` | `/plans/:id/destinations/:destinationId` | update | **200** | `PlanDto` (not 204) | +| `DELETE` | `/plans/:id` | delete | 204 | archives | +| `POST` | `/plans/bulk-delete` | delete | 200 | `{ deleted }` | +| `POST` | `/plans/bulk-status` | update | 200 | `{ updated }` | + +Register static paths (`generate`, `bulk-delete`, `bulk-status`) before `:id`. + +### Create + +```json +{ + "employeeId": "uuid", + "purpose": "sales", + "date": "2026-01-12", + "startBranchId": "uuid", + "endBranchId": "uuid", + "customerIds": ["uuid"], + "invoiceIds": ["uuid"], + "packingSlipIds": [], + "status": "draft" +} +``` + +`date` is `YYYY-MM-DD`. `customerIds` must be non-empty. Unique per employee+date+purpose → `409 Plan already exists for this employee`. + +- Sales + `packingSlipIds` → `400 Sales plans cannot include packing slips` +- Logistics + `invoiceIds` → `400 Logistics plans cannot include invoices` + +Response `date` is unix ms. Includes `routeGeometry`, `destinations`, `invoiceIds`, `packingSlipIds`. + +### Generate + +```json +{ + "employeeId": "uuid", + "purpose": "sales", + "from": "2026-01-12", + "to": "2026-01-25" +} +``` + +Materializes **active** plans from the employee’s cycles over `[from, to]` (inclusive calendar days). + +Requires settings (`GET /settings`). Errors: + +- `404 Settings not configured` +- `400 Invalid date range` (`to` before `from`) +- `400 Date is before the cycle start date` +- `400 User has no sales cycle` / `User has no logistics cycle` + +`skipped` counts days that already have a plan or have no matching weekday template. + +Generated plans are created with status **`active`**. + +### Destinations + +`POST /plans/:id/destinations`: + +- `customerId` required +- `afterDestinationId` optional: insert after that stop; omit to append +- Duplicate customer → `409 Customer is already on this plan` +- Unknown `afterDestinationId` → `400 Destination is not on this plan` + +`DELETE …/destinations/:destinationId`: + +- Last remaining stop → `400 A live plan must keep at least one destination` +- Unknown id → `404 Destination not found` + +Both return the full plan (route geometry is recomputed). + +List filters: `employeeId`, `purpose`, `date` (`YYYY-MM-DD`), `status`, `search`. + +--- + +## 24. CSV import + +`POST /{resource}/import` — `multipart/form-data`, field **`file`**. + +- Max 1_048_576 bytes +- Must be CSV mime or filename ending `.csv` +- Fail the whole batch on row errors +- Optional `status` column; omitted → `draft` +- `createdBy` / `updatedBy` = current user +- Plans have **no** import + +--- + +## 25. Suggested frontend flows + +### Session + +1. `POST /auth/login` → store both tokens. +2. Attach access token to every request. +3. On `401`, `POST /auth/refresh`; if that fails, logout. +4. Logout: `POST /auth/revoke` then drop tokens. +5. After login, `GET /auth/me` and cache `permissions` / `isSuperadmin` for nav and buttons. + +### Configuration screens + +Standard list + drawer/form. Status chip uses `/status` and `/bulk-status`. Import uses the CSV headers in each section. + +### Sales documents + +List without lines. Open detail for `products` / `images`. Creating an order from a request: `POST /sales-orders` with `salesRequestId` and any overrides. Packing slip / invoice can be seeded from parent IDs. + +### Field (cycles / plans) + +1. Ensure `PATCH /settings` has a `cycleStartDate`. +2. Create cycles per employee + purpose (`weekdays` object in, array out). +3. `POST /plans/generate` for a date range, or `POST /plans` for a one-off day. +4. Edit stops with destination add/remove; do not send `status` on plan PATCH. +5. Scope lists with `?purpose=sales` or `logistics` so privilege and filters match the screen. + +### Privilege editor + +1. `GET /privilege-keys?limit=200` for the matrix axes. +2. `GET /privileges/:id` for cells (`details`). +3. `PATCH /privileges/:id` with the full `details` array. +4. Assign with `PATCH /users/:id/privilege`. + +--- + +## 26. TypeScript shapes (copy) + +```ts +type Uuid = string; +type UnixMs = number; +type DecimalString = string; +type CoreStatus = 'draft' | 'active' | 'archived'; +type FieldPurpose = 'sales' | 'logistics'; +type Weekday = + | 'monday' + | 'tuesday' + | 'wednesday' + | 'thursday' + | 'friday' + | 'saturday' + | 'sunday'; +type PrivilegeAction = 'view' | 'create' | 'update' | 'delete' | 'import'; + +type PaginationMeta = { + currentPage: number; + itemCount: number; + itemsPerPage: number; + totalItems: number; + totalPages: number; +}; + +type Paginated = { data: T[]; meta: PaginationMeta }; + +type Audit = { + createdAt: UnixMs; + updatedAt: UnixMs; + createdBy: Uuid; + updatedBy: Uuid; +}; + +type TokenPair = { accessToken: string; refreshToken: string }; + +type Me = { + id: Uuid; + username: string; + isSuperadmin: boolean; + privilege: { id: Uuid; name: string; code: string } | null; + permissions: Record< + string, + Record + >; +}; + +type RouteGeometry = { + type: 'LineString'; + coordinates: ReadonlyArray; // [lng, lat] +}; + +type HttpError = { + statusCode: number; + message: string | string[]; + error?: string; + errors?: string[]; +}; +``` + +--- + +## 27. Endpoint index + +Public: `GET /`, `POST /auth/register`, `POST /auth/login`, `POST /auth/refresh`, `POST /auth/revoke`. + +Bearer: `GET /auth/me`, `PATCH /users/:id/privilege`, `GET /privilege-keys`, `GET /settings`, `PATCH /settings`. + +CRUD families: `/privileges`, `/divisions`, `/branches`, `/customers`, `/employees`, `/products`, `/sales-requests`, `/sales-orders`, `/packing-slips`, `/sales-invoices`, `/sales-payments`, `/cycles`, `/plans`. + +Extras: customer contacts, `POST /plans/generate`, plan destinations. + +Live schema: `GET /docs-json` (non-production, or `SWAGGER_ENABLED=true`).