# 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>; }; 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`).