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.
This commit is contained in:
shancheas
2026-08-25 19:14:39 +07:00
parent a5a42cf87e
commit 44b0ef0168
4 changed files with 1010 additions and 0 deletions
+966
View File
@@ -0,0 +1,966 @@
# TrackGo HTTP API — frontend reference
Static contract for a frontend agent or UI. Source of truth is this backends 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 <accessToken>` 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`: 332 chars, `^[a-zA-Z0-9_]+$`, stored lowercased
- `password`: 872 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": "<opaque>" }
```
`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 <accessToken>
```
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 employees 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<T> = { 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<PrivilegeAction, boolean>
>;
};
type RouteGeometry = {
type: 'LineString';
coordinates: ReadonlyArray<readonly [number, number]>; // [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`).