Files
trackgo-fe/api.md
T
shancheas 87bfe50f0e feat: add sales timeline module and company settings configuration
- Introduced a new sales timeline module with routes and lazy loading for efficient loading.
- Updated privilege keys in `api.md` to include `ADMIN.SALES.ACTIVITIES.TIMELINE` for access control.
- Enhanced menu data to include the timeline option, improving navigation.
- Added company settings module with configuration options for cycle start date and check-in radius.
- Implemented remote services and data handling for company settings, ensuring accurate data management.
- Enhanced language support for both English and Indonesian in navigation and company settings.

These changes significantly improve the application's functionality by adding a timeline feature for sales activities and a comprehensive settings module for company configurations, enhancing user experience and data management.
2026-09-01 20:36:57 +07:00

961 lines
32 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `ADMIN.SETTINGS.USER.PRIVILEGES` `view`). Keys use `Group.Parent.Module` or `Group.Parent.Module.Submodule`:
| code | label |
| ---- | ----- |
| `ADMIN.SETTINGS.USER.PRIVILEGES` | Privileges |
| `ADMIN.SETTINGS.USER.USERS` | Users |
| `ADMIN.SETTINGS.DATA.DIVISION` | Divisions |
| `ADMIN.SETTINGS.DATA.BRANCH` | Branches |
| `ADMIN.SETTINGS.DATA.CUSTOMER` | Customers |
| `ADMIN.SETTINGS.DATA.PRODUCT` | Products |
| `ADMIN.SETTINGS.DATA.SETTING` | Company settings |
| `ADMIN.SALES.DATA.EMPLOYEE` | Employees |
| `ADMIN.SALES.DATA.CYCLE` | Sales cycles |
| `ADMIN.SALES.ACTIVITIES.REQUEST` | Sales requests |
| `ADMIN.SALES.ACTIVITIES.ORDER` | Sales orders |
| `ADMIN.SALES.ACTIVITIES.INVOICE` | Sales invoices |
| `ADMIN.SALES.ACTIVITIES.PAYMENT` | Sales payments |
| `ADMIN.SALES.ACTIVITIES.PLAN` | Sales plans |
| `ADMIN.SALES.ACTIVITIES.TIMELINE` | Sales timeline |
| `ADMIN.SALES.REPORT` | Sales reports |
| `ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP` | Packing slips |
| `ADMIN.LOGISTICS.DATA.CYCLE` | Logistics cycles |
| `ADMIN.LOGISTICS.ACTIVITIES.PLAN` | Logistics plans |
| `ADMIN.LOGISTICS.REPORT` | Logistics reports |
| `MOBILE.SALES.PLAN` | Sales plans (mobile) |
| `MOBILE.SALES.PLAN.ATTENDANCE` | Branch attendance |
| `MOBILE.SALES.VISIT` | Customer visits |
| `MOBILE.SALES.TIMELINE` | Sales timeline (mobile) |
### Field purpose
Cycles and plans do **not** use a single key. Privilege is resolved from `purpose`:
| purpose | cycle keys | plan keys |
| ----------- | ---------- | --------- |
| `sales` | `ADMIN.SALES.DATA.CYCLE` | `ADMIN.SALES.ACTIVITIES.PLAN`, `MOBILE.SALES.PLAN` |
| `logistics` | `ADMIN.LOGISTICS.DATA.CYCLE` | `ADMIN.LOGISTICS.ACTIVITIES.PLAN`, `MOBILE.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`).