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:
@@ -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 <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`: 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": "<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 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<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`).
|
||||
Reference in New Issue
Block a user