Compare commits
10
Commits
d28506e878
...
afed5ff0f5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afed5ff0f5 | ||
|
|
627aeac4a0 | ||
|
|
4c45a4371e | ||
|
|
790725e227 | ||
|
|
8a61c94078 | ||
|
|
f635ebeda0 | ||
|
|
c9f9b31abf | ||
|
|
34d4f2c120 | ||
|
|
ddbe8a9ef8 | ||
|
|
cdcc508947 |
@@ -36,7 +36,7 @@ interface PaginationMeta {
|
||||
|
||||
- Mark every list endpoint with `@Pagination()`
|
||||
- Return `{ data, total }` from the handler — **never** build `meta` in the service or controller
|
||||
- Query: `page`/`limit` or `offset`/`limit` (defaults `page=1`, `limit=10`; max limit `200`)
|
||||
- Query: `page`/`limit` or `offset`/`limit` (defaults `page=1`, `limit=10`; max limit `200`) plus `orderBy`/`orderType` (`ASC` | `DESC`, default `ASC`)
|
||||
- Use `@RawResponse()` for file downloads / health probes that must skip wrapping
|
||||
- Non-list handlers (detail, create, update, delete, status, import) pass through **unwrapped**
|
||||
|
||||
|
||||
@@ -35,9 +35,10 @@ Register static write paths (`import`, `bulk-delete`, `bulk-status`) **before**
|
||||
List requirements:
|
||||
|
||||
- Query filters for the resource’s own attributes **plus** `search` (case-insensitive match on the module’s searchable text columns; AND with other filters)
|
||||
- Shared pagination query (`page`/`limit` or `offset`/`limit`) via `PaginationQueryDto`
|
||||
- Shared pagination query (`page`/`limit` or `offset`/`limit`) plus `orderBy`/`orderType` via `PaginationQueryDto`
|
||||
- Handler **must** use `@Pagination()` and return `{ data, total }` — never build `meta` here (see `.cursor/rules/pagination-response.mdc`)
|
||||
- Service `visibleFields` whitelist: default **all non-secret** attributes; modules may narrow. Project in the **service**, not the controller
|
||||
- FK relations in list/detail (and write responses that reuse the mapper) MUST be nested objects via `pickRelation` — see `.cursor/rules/relation-response.mdc`
|
||||
- List query must be extendable (e.g. `extendListQuery(qb, filters)` on the repository/service) so joins/extra predicates can be added without forking list
|
||||
|
||||
## Write controller
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
description: List/detail (and write responses that reuse the mapper) embed FK relations as objects via pickRelation
|
||||
globs: "src/modules/**/*.ts,src/common/http/response/**/*.ts"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Relation Response Objects
|
||||
|
||||
List, detail, and write handlers that reuse the same mapper MUST embed foreign keys as nested objects, not bare ids.
|
||||
|
||||
## Field lists
|
||||
|
||||
- Default catalog fields: `DEFAULT_RELATION_FIELDS` (`id`, `code`, `name`) from `src/common/http/response/`
|
||||
- Override per entity with a module/local constant (users: `USER_RELATION_FIELDS` = `id`, `username`)
|
||||
- Use `pickRelation(source, fields)` only — do not hand-roll partial copies
|
||||
|
||||
## Mapping
|
||||
|
||||
- Request DTOs still accept `*Id` (`divisionId`); the response key is the relation name (`division`, not `divisionId`)
|
||||
- Null FK → `null` (not omitted)
|
||||
- Never expose secrets (`passwordHash`, tokens) in relation objects
|
||||
- Load relations in the repository (joins or batch-load); map with `pickRelation` in the service
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
return { divisionId: branch.divisionId, createdBy: branch.createdBy }
|
||||
|
||||
// GOOD
|
||||
return {
|
||||
division: pickRelation(branch.division, DEFAULT_RELATION_FIELDS),
|
||||
createdBy: pickRelation(branch.createdByUser, USER_RELATION_FIELDS),
|
||||
}
|
||||
```
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
# TrackGo HTTP API
|
||||
|
||||
JSON keys are camelCase. IDs are UUID v4. List endpoints return `{ data, meta }`.
|
||||
|
||||
List query includes shared pagination (`page`/`limit` or `offset`/`limit`) plus **`orderBy`** (resource field name) and **`orderType`** (`ASC` or `DESC`, default `ASC`). Unknown `orderBy` values are rejected. Defaults when omitted: users `username`; cycles `cycleNumber`; plans `date`; privilege-keys `sortOrder` then `code`; other lists `code`. Foreign keys on list/detail responses are nested objects (`{ id, code, name }` or `{ id, username }` / `{ id, code }`), not bare UUIDs.
|
||||
|
||||
List filters: `username`, `privilegeId`, `status`, `search` (username), `orderBy`, `orderType`.
|
||||
|
||||
## Auth
|
||||
|
||||
### `POST /auth/register` — public — `201`
|
||||
|
||||
Creates a **draft** user. Does **not** issue tokens.
|
||||
|
||||
```json
|
||||
{ "username": "alice", "password": "password123" }
|
||||
```
|
||||
|
||||
Response: `{ "id": "uuid", "username": "alice", "status": "draft" }`.
|
||||
|
||||
Activate with `PATCH /users/:id/status` `{ "status": "active" }` (or SQL bootstrap) then `POST /auth/login`.
|
||||
|
||||
### `POST /auth/login` — public — `200`
|
||||
|
||||
Same body as register. Returns `{ accessToken, refreshToken }`.
|
||||
|
||||
Login, refresh, and JWT validation require `user.status === "active"`. If the user is assigned to an employee, that employee must also be `active`. Failures use `401` with a generic credentials message.
|
||||
|
||||
### `POST /auth/refresh` — public — `200`
|
||||
|
||||
### `POST /auth/revoke` — public — `204`
|
||||
|
||||
### `GET /auth/me` — bearer — `200`
|
||||
|
||||
## Users
|
||||
|
||||
Key: `USERS`. **Standard CRUD + import** (list, detail, create, update, status, delete, bulk-delete, bulk-status, import). Extra: `PATCH /users/:id/privilege`.
|
||||
|
||||
| Method | Path | Action | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET` | `/users` | view | 200 |
|
||||
| `GET` | `/users/:id` | view | 200 |
|
||||
| `POST` | `/users` | create | 201 |
|
||||
| `PATCH` | `/users/:id` | update | 200 |
|
||||
| `PATCH` | `/users/:id/status` | update | 200 |
|
||||
| `PATCH` | `/users/:id/privilege` | update | 200 |
|
||||
| `DELETE` | `/users/:id` | delete | 204 |
|
||||
| `POST` | `/users/bulk-delete` | delete | 200 |
|
||||
| `POST` | `/users/bulk-status` | update | 200 |
|
||||
| `POST` | `/users/import` | import | 200 |
|
||||
|
||||
**Create:** `{ username, password, privilegeId?, status?, employeeId? }`. Username 3–32, `^[a-zA-Z0-9_]+$`, stored lowercased. Password 8–72, write-only. Omit status → `draft`. Never send `isSuperadmin` / `passwordHash`.
|
||||
|
||||
Optional `employeeId` links an existing employee. Unique assigned user → `409`.
|
||||
|
||||
**Update** `PATCH /users/:id`: `username?`, `password?`, `privilegeId?` (`null` clears), `employeeId?` (`null` unlinks). No `status`. `employeeId` reassigns the linked employee.
|
||||
|
||||
**Privilege:** `{ privilegeId }` (`null` clears). Assigned privilege must be **active**. Response is the full `UserDto`.
|
||||
|
||||
List filters: `username`, `privilegeId`, `status`, `search` (username).
|
||||
|
||||
**DTO:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"username": "alice",
|
||||
"isSuperadmin": false,
|
||||
"privilege": { "id": "uuid", "code": "ADMIN", "name": "Administrator" },
|
||||
"employee": { "id": "uuid", "code": "EMP_01", "name": "Ada Lovelace" },
|
||||
"status": "active",
|
||||
"createdAt": 1710000000000,
|
||||
"updatedAt": 1710000000000,
|
||||
"createdBy": { "id": "uuid", "username": "admin" },
|
||||
"updatedBy": { "id": "uuid", "username": "admin" }
|
||||
}
|
||||
```
|
||||
|
||||
`privilege` / `employee` may be `null`. CSV required: `username`, `password`. Optional: `privilegeId`, `status`. Delete of a user still referenced as `created_by` / `updated_by` → `409`.
|
||||
|
||||
Bootstrap: first user is draft until `UPDATE users SET status = 'active'`.
|
||||
|
||||
## Employees
|
||||
|
||||
Create/update optional `userId` (assign an existing login user) or nested `user` (`id?`, `username?`, `password?`). Nested `user` without `id` creates a login user (`username` + `password` required) or updates the currently linked username. `user.id` / `userId` links an existing user; `username` may be updated, but `password` is rejected (use `PATCH /users/:id`). Nested `user` cannot set `privilegeId`. `user: null` or `userId: null` unlinks. Users link the other way with `employeeId`. DTO nests `user: { id, username } | null`. List filter `userId`. List filter `position` as one or more of `sales` | `driver` | `crew` (`?position=sales&position=driver`). CSV optional `userId`. Unique assigned user → `409`.
|
||||
@@ -0,0 +1,35 @@
|
||||
CREATE TABLE "customers" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"code" varchar(16) NOT NULL,
|
||||
"name" varchar(64) NOT NULL,
|
||||
"phone" text NOT NULL,
|
||||
"address" text NOT NULL,
|
||||
"latitude" double precision,
|
||||
"longitude" double precision,
|
||||
"nfc_id" text,
|
||||
"status" text DEFAULT 'draft' NOT NULL,
|
||||
"created_at" bigint NOT NULL,
|
||||
"updated_at" bigint NOT NULL,
|
||||
"created_by" uuid NOT NULL,
|
||||
"updated_by" uuid NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "customer_contacts" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"customer_id" uuid NOT NULL,
|
||||
"name" varchar(64) NOT NULL,
|
||||
"job_title" varchar(64),
|
||||
"phone" text,
|
||||
"mobile_phone" text,
|
||||
"notes" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "customers" ADD CONSTRAINT "customers_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "customers" ADD CONSTRAINT "customers_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "customer_contacts" ADD CONSTRAINT "customer_contacts_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "customers_code_unique" ON "customers" USING btree ("code");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "customers_nfc_id_unique" ON "customers" USING btree ("nfc_id");--> statement-breakpoint
|
||||
CREATE INDEX "customer_contacts_customer_id_idx" ON "customer_contacts" USING btree ("customer_id");
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES
|
||||
('CONFIGURATION.CUSTOMER', 'Customers', 5);
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE "employees" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"code" varchar(16) NOT NULL,
|
||||
"name" varchar(64) NOT NULL,
|
||||
"phone" text NOT NULL,
|
||||
"position" text NOT NULL,
|
||||
"status" text DEFAULT 'draft' NOT NULL,
|
||||
"created_at" bigint NOT NULL,
|
||||
"updated_at" bigint NOT NULL,
|
||||
"created_by" uuid NOT NULL,
|
||||
"updated_by" uuid NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "employees" ADD CONSTRAINT "employees_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "employees" ADD CONSTRAINT "employees_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "employees_code_unique" ON "employees" USING btree ("code");
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES
|
||||
('CONFIGURATION.EMPLOYEE', 'Employees', 6);
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE "products" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"code" varchar(32) NOT NULL,
|
||||
"name" varchar(128) NOT NULL,
|
||||
"unit" varchar(16),
|
||||
"price" numeric(18, 4),
|
||||
"brand" varchar(64),
|
||||
"status" text DEFAULT 'draft' NOT NULL,
|
||||
"created_at" bigint NOT NULL,
|
||||
"updated_at" bigint NOT NULL,
|
||||
"created_by" uuid NOT NULL,
|
||||
"updated_by" uuid NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "products" ADD CONSTRAINT "products_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "products" ADD CONSTRAINT "products_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "products_code_unique" ON "products" USING btree ("code");
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES
|
||||
('CONFIGURATION.PRODUCT', 'Products', 7);
|
||||
@@ -0,0 +1,252 @@
|
||||
CREATE TABLE "document_sequences" (
|
||||
"prefix" varchar(8) NOT NULL,
|
||||
"period" varchar(8) NOT NULL,
|
||||
"last_value" integer NOT NULL,
|
||||
CONSTRAINT "document_sequences_prefix_period_pk" PRIMARY KEY("prefix","period")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sales_requests" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"code" varchar(32) NOT NULL,
|
||||
"date" bigint NOT NULL,
|
||||
"sales_person_id" uuid NOT NULL,
|
||||
"branch_id" uuid NOT NULL,
|
||||
"division_id" uuid NOT NULL,
|
||||
"customer_id" uuid NOT NULL,
|
||||
"address" text NOT NULL,
|
||||
"latitude" double precision,
|
||||
"longitude" double precision,
|
||||
"notes" text,
|
||||
"status" text DEFAULT 'draft' NOT NULL,
|
||||
"created_at" bigint NOT NULL,
|
||||
"updated_at" bigint NOT NULL,
|
||||
"created_by" uuid NOT NULL,
|
||||
"updated_by" uuid NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sales_request_products" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"sales_request_id" uuid NOT NULL,
|
||||
"product_id" uuid NOT NULL,
|
||||
"quantity" numeric(18, 4) NOT NULL,
|
||||
"price" numeric(18, 4) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sales_request_images" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"sales_request_id" uuid NOT NULL,
|
||||
"url" varchar(2048) NOT NULL,
|
||||
"description" varchar(255)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sales_orders" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"code" varchar(32) NOT NULL,
|
||||
"sales_request_id" uuid,
|
||||
"date" bigint NOT NULL,
|
||||
"sales_person_id" uuid NOT NULL,
|
||||
"branch_id" uuid NOT NULL,
|
||||
"division_id" uuid NOT NULL,
|
||||
"customer_id" uuid NOT NULL,
|
||||
"address" text NOT NULL,
|
||||
"latitude" double precision,
|
||||
"longitude" double precision,
|
||||
"notes" text,
|
||||
"status" text DEFAULT 'draft' NOT NULL,
|
||||
"created_at" bigint NOT NULL,
|
||||
"updated_at" bigint NOT NULL,
|
||||
"created_by" uuid NOT NULL,
|
||||
"updated_by" uuid NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sales_order_products" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"sales_order_id" uuid NOT NULL,
|
||||
"product_id" uuid NOT NULL,
|
||||
"quantity" numeric(18, 4) NOT NULL,
|
||||
"price" numeric(18, 4) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sales_order_images" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"sales_order_id" uuid NOT NULL,
|
||||
"url" varchar(2048) NOT NULL,
|
||||
"description" varchar(255)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "packing_slips" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"code" varchar(32) NOT NULL,
|
||||
"sales_order_id" uuid,
|
||||
"sales_order_number" varchar(32),
|
||||
"date" bigint NOT NULL,
|
||||
"customer_id" uuid NOT NULL,
|
||||
"address" text NOT NULL,
|
||||
"latitude" double precision,
|
||||
"longitude" double precision,
|
||||
"notes" text,
|
||||
"status" text DEFAULT 'draft' NOT NULL,
|
||||
"created_at" bigint NOT NULL,
|
||||
"updated_at" bigint NOT NULL,
|
||||
"created_by" uuid NOT NULL,
|
||||
"updated_by" uuid NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "packing_slip_products" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"packing_slip_id" uuid NOT NULL,
|
||||
"product_id" uuid NOT NULL,
|
||||
"quantity" numeric(18, 4) NOT NULL,
|
||||
"price" numeric(18, 4) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sales_invoices" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"code" varchar(32) NOT NULL,
|
||||
"date" bigint NOT NULL,
|
||||
"sales_person_id" uuid NOT NULL,
|
||||
"branch_id" uuid NOT NULL,
|
||||
"division_id" uuid NOT NULL,
|
||||
"customer_id" uuid NOT NULL,
|
||||
"sales_order_id" uuid,
|
||||
"sales_order_code" varchar(32),
|
||||
"packing_slip_id" uuid,
|
||||
"packing_slip_code" varchar(32),
|
||||
"balance" numeric(18, 4) NOT NULL,
|
||||
"notes" text,
|
||||
"status" text DEFAULT 'draft' NOT NULL,
|
||||
"created_at" bigint NOT NULL,
|
||||
"updated_at" bigint NOT NULL,
|
||||
"created_by" uuid NOT NULL,
|
||||
"updated_by" uuid NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sales_invoice_products" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"sales_invoice_id" uuid NOT NULL,
|
||||
"product_id" uuid NOT NULL,
|
||||
"quantity" numeric(18, 4) NOT NULL,
|
||||
"price" numeric(18, 4) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sales_payments" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"code" varchar(32) NOT NULL,
|
||||
"date" bigint NOT NULL,
|
||||
"notes" text,
|
||||
"status" text DEFAULT 'draft' NOT NULL,
|
||||
"created_at" bigint NOT NULL,
|
||||
"updated_at" bigint NOT NULL,
|
||||
"created_by" uuid NOT NULL,
|
||||
"updated_by" uuid NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sales_payment_images" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"sales_payment_id" uuid NOT NULL,
|
||||
"url" varchar(2048) NOT NULL,
|
||||
"description" varchar(255)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sales_payment_invoices" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"sales_payment_id" uuid NOT NULL,
|
||||
"sales_invoice_id" uuid NOT NULL,
|
||||
"amount" numeric(18, 4) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "sales_requests" ADD CONSTRAINT "sales_requests_sales_person_id_employees_id_fk" FOREIGN KEY ("sales_person_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_requests" ADD CONSTRAINT "sales_requests_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_requests" ADD CONSTRAINT "sales_requests_division_id_divisions_id_fk" FOREIGN KEY ("division_id") REFERENCES "public"."divisions"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_requests" ADD CONSTRAINT "sales_requests_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_requests" ADD CONSTRAINT "sales_requests_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_requests" ADD CONSTRAINT "sales_requests_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_request_products" ADD CONSTRAINT "sales_request_products_sales_request_id_sales_requests_id_fk" FOREIGN KEY ("sales_request_id") REFERENCES "public"."sales_requests"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_request_products" ADD CONSTRAINT "sales_request_products_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_request_images" ADD CONSTRAINT "sales_request_images_sales_request_id_sales_requests_id_fk" FOREIGN KEY ("sales_request_id") REFERENCES "public"."sales_requests"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_orders" ADD CONSTRAINT "sales_orders_sales_request_id_sales_requests_id_fk" FOREIGN KEY ("sales_request_id") REFERENCES "public"."sales_requests"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_orders" ADD CONSTRAINT "sales_orders_sales_person_id_employees_id_fk" FOREIGN KEY ("sales_person_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_orders" ADD CONSTRAINT "sales_orders_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_orders" ADD CONSTRAINT "sales_orders_division_id_divisions_id_fk" FOREIGN KEY ("division_id") REFERENCES "public"."divisions"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_orders" ADD CONSTRAINT "sales_orders_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_orders" ADD CONSTRAINT "sales_orders_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_orders" ADD CONSTRAINT "sales_orders_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_order_products" ADD CONSTRAINT "sales_order_products_sales_order_id_sales_orders_id_fk" FOREIGN KEY ("sales_order_id") REFERENCES "public"."sales_orders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_order_products" ADD CONSTRAINT "sales_order_products_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_order_images" ADD CONSTRAINT "sales_order_images_sales_order_id_sales_orders_id_fk" FOREIGN KEY ("sales_order_id") REFERENCES "public"."sales_orders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "packing_slips" ADD CONSTRAINT "packing_slips_sales_order_id_sales_orders_id_fk" FOREIGN KEY ("sales_order_id") REFERENCES "public"."sales_orders"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "packing_slips" ADD CONSTRAINT "packing_slips_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "packing_slips" ADD CONSTRAINT "packing_slips_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "packing_slips" ADD CONSTRAINT "packing_slips_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "packing_slip_products" ADD CONSTRAINT "packing_slip_products_packing_slip_id_packing_slips_id_fk" FOREIGN KEY ("packing_slip_id") REFERENCES "public"."packing_slips"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "packing_slip_products" ADD CONSTRAINT "packing_slip_products_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_invoices" ADD CONSTRAINT "sales_invoices_sales_person_id_employees_id_fk" FOREIGN KEY ("sales_person_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_invoices" ADD CONSTRAINT "sales_invoices_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_invoices" ADD CONSTRAINT "sales_invoices_division_id_divisions_id_fk" FOREIGN KEY ("division_id") REFERENCES "public"."divisions"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_invoices" ADD CONSTRAINT "sales_invoices_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_invoices" ADD CONSTRAINT "sales_invoices_sales_order_id_sales_orders_id_fk" FOREIGN KEY ("sales_order_id") REFERENCES "public"."sales_orders"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_invoices" ADD CONSTRAINT "sales_invoices_packing_slip_id_packing_slips_id_fk" FOREIGN KEY ("packing_slip_id") REFERENCES "public"."packing_slips"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_invoices" ADD CONSTRAINT "sales_invoices_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_invoices" ADD CONSTRAINT "sales_invoices_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_invoice_products" ADD CONSTRAINT "sales_invoice_products_sales_invoice_id_sales_invoices_id_fk" FOREIGN KEY ("sales_invoice_id") REFERENCES "public"."sales_invoices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_invoice_products" ADD CONSTRAINT "sales_invoice_products_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_payments" ADD CONSTRAINT "sales_payments_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_payments" ADD CONSTRAINT "sales_payments_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_payment_images" ADD CONSTRAINT "sales_payment_images_sales_payment_id_sales_payments_id_fk" FOREIGN KEY ("sales_payment_id") REFERENCES "public"."sales_payments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_payment_invoices" ADD CONSTRAINT "sales_payment_invoices_sales_payment_id_sales_payments_id_fk" FOREIGN KEY ("sales_payment_id") REFERENCES "public"."sales_payments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_payment_invoices" ADD CONSTRAINT "sales_payment_invoices_sales_invoice_id_sales_invoices_id_fk" FOREIGN KEY ("sales_invoice_id") REFERENCES "public"."sales_invoices"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "sales_requests_code_unique" ON "sales_requests" USING btree ("code");
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "sales_orders_code_unique" ON "sales_orders" USING btree ("code");
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "packing_slips_code_unique" ON "packing_slips" USING btree ("code");
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "sales_invoices_code_unique" ON "sales_invoices" USING btree ("code");
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "sales_payments_code_unique" ON "sales_payments" USING btree ("code");
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES
|
||||
('SALES.REQUEST', 'Sales requests', 8),
|
||||
('SALES.ORDER', 'Sales orders', 9),
|
||||
('SALES.PACKING_SLIP', 'Packing slips', 10),
|
||||
('SALES.INVOICE', 'Sales invoices', 11),
|
||||
('SALES.PAYMENT', 'Sales payments', 12);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "packing_slips" ADD COLUMN "sales_person_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "packing_slips" ADD COLUMN "branch_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "packing_slips" ADD COLUMN "division_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "sales_invoices" ADD COLUMN "address" text;--> statement-breakpoint
|
||||
ALTER TABLE "sales_invoices" ADD COLUMN "latitude" double precision;--> statement-breakpoint
|
||||
ALTER TABLE "sales_invoices" ADD COLUMN "longitude" double precision;--> statement-breakpoint
|
||||
ALTER TABLE "sales_payments" ADD COLUMN "sales_person_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "sales_payments" ADD COLUMN "branch_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "sales_payments" ADD COLUMN "division_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "sales_payments" ADD COLUMN "customer_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "sales_payments" ADD COLUMN "address" text;--> statement-breakpoint
|
||||
ALTER TABLE "sales_payments" ADD COLUMN "latitude" double precision;--> statement-breakpoint
|
||||
ALTER TABLE "sales_payments" ADD COLUMN "longitude" double precision;--> statement-breakpoint
|
||||
CREATE TABLE "packing_slip_images" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"packing_slip_id" uuid NOT NULL,
|
||||
"url" varchar(2048) NOT NULL,
|
||||
"description" varchar(255)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sales_invoice_images" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"sales_invoice_id" uuid NOT NULL,
|
||||
"url" varchar(2048) NOT NULL,
|
||||
"description" varchar(255)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sales_payment_products" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"sales_payment_id" uuid NOT NULL,
|
||||
"product_id" uuid NOT NULL,
|
||||
"quantity" numeric(18, 4) NOT NULL,
|
||||
"price" numeric(18, 4) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "packing_slip_images" ADD CONSTRAINT "packing_slip_images_packing_slip_id_packing_slips_id_fk" FOREIGN KEY ("packing_slip_id") REFERENCES "public"."packing_slips"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_invoice_images" ADD CONSTRAINT "sales_invoice_images_sales_invoice_id_sales_invoices_id_fk" FOREIGN KEY ("sales_invoice_id") REFERENCES "public"."sales_invoices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sales_payment_products" ADD CONSTRAINT "sales_payment_products_sales_payment_id_sales_payments_id_fk" FOREIGN KEY ("sales_payment_id") REFERENCES "public"."sales_payments"("id") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -0,0 +1,16 @@
|
||||
ALTER TABLE "packing_slips" DROP COLUMN IF EXISTS "sales_person_id";--> statement-breakpoint
|
||||
ALTER TABLE "packing_slips" DROP COLUMN IF EXISTS "branch_id";--> statement-breakpoint
|
||||
ALTER TABLE "packing_slips" DROP COLUMN IF EXISTS "division_id";--> statement-breakpoint
|
||||
DROP TABLE IF EXISTS "packing_slip_images";--> statement-breakpoint
|
||||
ALTER TABLE "sales_invoices" DROP COLUMN IF EXISTS "address";--> statement-breakpoint
|
||||
ALTER TABLE "sales_invoices" DROP COLUMN IF EXISTS "latitude";--> statement-breakpoint
|
||||
ALTER TABLE "sales_invoices" DROP COLUMN IF EXISTS "longitude";--> statement-breakpoint
|
||||
DROP TABLE IF EXISTS "sales_invoice_images";--> statement-breakpoint
|
||||
ALTER TABLE "sales_payments" DROP COLUMN IF EXISTS "sales_person_id";--> statement-breakpoint
|
||||
ALTER TABLE "sales_payments" DROP COLUMN IF EXISTS "branch_id";--> statement-breakpoint
|
||||
ALTER TABLE "sales_payments" DROP COLUMN IF EXISTS "division_id";--> statement-breakpoint
|
||||
ALTER TABLE "sales_payments" DROP COLUMN IF EXISTS "customer_id";--> statement-breakpoint
|
||||
ALTER TABLE "sales_payments" DROP COLUMN IF EXISTS "address";--> statement-breakpoint
|
||||
ALTER TABLE "sales_payments" DROP COLUMN IF EXISTS "latitude";--> statement-breakpoint
|
||||
ALTER TABLE "sales_payments" DROP COLUMN IF EXISTS "longitude";--> statement-breakpoint
|
||||
DROP TABLE IF EXISTS "sales_payment_products";
|
||||
@@ -0,0 +1,112 @@
|
||||
CREATE TABLE "company_settings" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"cycle_start_date" bigint NOT NULL,
|
||||
"status" text DEFAULT 'draft' NOT NULL,
|
||||
"created_at" bigint NOT NULL,
|
||||
"updated_at" bigint NOT NULL,
|
||||
"created_by" uuid NOT NULL,
|
||||
"updated_by" uuid NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "cycles" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"employee_id" uuid NOT NULL,
|
||||
"purpose" text NOT NULL,
|
||||
"cycle_number" integer NOT NULL,
|
||||
"status" text DEFAULT 'draft' NOT NULL,
|
||||
"created_at" bigint NOT NULL,
|
||||
"updated_at" bigint NOT NULL,
|
||||
"created_by" uuid NOT NULL,
|
||||
"updated_by" uuid NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "cycle_weekdays" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"cycle_id" uuid NOT NULL,
|
||||
"weekday" text NOT NULL,
|
||||
"start_branch_id" uuid NOT NULL,
|
||||
"end_branch_id" uuid NOT NULL,
|
||||
"route_geometry" jsonb NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "cycle_destinations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"cycle_weekday_id" uuid NOT NULL,
|
||||
"customer_id" uuid NOT NULL,
|
||||
"sort_order" integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "plans" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"employee_id" uuid NOT NULL,
|
||||
"purpose" text NOT NULL,
|
||||
"date" bigint NOT NULL,
|
||||
"start_branch_id" uuid NOT NULL,
|
||||
"end_branch_id" uuid NOT NULL,
|
||||
"route_geometry" jsonb NOT NULL,
|
||||
"status" text DEFAULT 'draft' NOT NULL,
|
||||
"created_at" bigint NOT NULL,
|
||||
"updated_at" bigint NOT NULL,
|
||||
"created_by" uuid NOT NULL,
|
||||
"updated_by" uuid NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "plan_destinations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"plan_id" uuid NOT NULL,
|
||||
"customer_id" uuid NOT NULL,
|
||||
"sort_order" integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "plan_invoices" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"plan_id" uuid NOT NULL,
|
||||
"invoice_id" uuid NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "plan_packing_slips" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"plan_id" uuid NOT NULL,
|
||||
"packing_slip_id" uuid NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "company_settings" ADD CONSTRAINT "company_settings_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "company_settings" ADD CONSTRAINT "company_settings_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "cycles" ADD CONSTRAINT "cycles_employee_id_employees_id_fk" FOREIGN KEY ("employee_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "cycles" ADD CONSTRAINT "cycles_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "cycles" ADD CONSTRAINT "cycles_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "cycle_weekdays" ADD CONSTRAINT "cycle_weekdays_cycle_id_cycles_id_fk" FOREIGN KEY ("cycle_id") REFERENCES "public"."cycles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "cycle_weekdays" ADD CONSTRAINT "cycle_weekdays_start_branch_id_branches_id_fk" FOREIGN KEY ("start_branch_id") REFERENCES "public"."branches"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "cycle_weekdays" ADD CONSTRAINT "cycle_weekdays_end_branch_id_branches_id_fk" FOREIGN KEY ("end_branch_id") REFERENCES "public"."branches"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "cycle_destinations" ADD CONSTRAINT "cycle_destinations_cycle_weekday_id_cycle_weekdays_id_fk" FOREIGN KEY ("cycle_weekday_id") REFERENCES "public"."cycle_weekdays"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "cycle_destinations" ADD CONSTRAINT "cycle_destinations_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plans" ADD CONSTRAINT "plans_employee_id_employees_id_fk" FOREIGN KEY ("employee_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plans" ADD CONSTRAINT "plans_start_branch_id_branches_id_fk" FOREIGN KEY ("start_branch_id") REFERENCES "public"."branches"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plans" ADD CONSTRAINT "plans_end_branch_id_branches_id_fk" FOREIGN KEY ("end_branch_id") REFERENCES "public"."branches"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plans" ADD CONSTRAINT "plans_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plans" ADD CONSTRAINT "plans_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plan_destinations" ADD CONSTRAINT "plan_destinations_plan_id_plans_id_fk" FOREIGN KEY ("plan_id") REFERENCES "public"."plans"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plan_destinations" ADD CONSTRAINT "plan_destinations_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plan_invoices" ADD CONSTRAINT "plan_invoices_plan_id_plans_id_fk" FOREIGN KEY ("plan_id") REFERENCES "public"."plans"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plan_invoices" ADD CONSTRAINT "plan_invoices_invoice_id_sales_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."sales_invoices"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plan_packing_slips" ADD CONSTRAINT "plan_packing_slips_plan_id_plans_id_fk" FOREIGN KEY ("plan_id") REFERENCES "public"."plans"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plan_packing_slips" ADD CONSTRAINT "plan_packing_slips_packing_slip_id_packing_slips_id_fk" FOREIGN KEY ("packing_slip_id") REFERENCES "public"."packing_slips"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "cycles_employee_purpose_number_live_unique" ON "cycles" USING btree ("employee_id","purpose","cycle_number") WHERE "status" <> 'archived';--> statement-breakpoint
|
||||
CREATE INDEX "cycles_employee_id_idx" ON "cycles" USING btree ("employee_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "cycle_weekdays_cycle_weekday_unique" ON "cycle_weekdays" USING btree ("cycle_id","weekday");--> statement-breakpoint
|
||||
CREATE INDEX "cycle_weekdays_cycle_id_idx" ON "cycle_weekdays" USING btree ("cycle_id");--> statement-breakpoint
|
||||
CREATE INDEX "cycle_destinations_weekday_id_idx" ON "cycle_destinations" USING btree ("cycle_weekday_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "plans_employee_purpose_date_live_unique" ON "plans" USING btree ("employee_id","purpose","date") WHERE "status" <> 'archived';--> statement-breakpoint
|
||||
CREATE INDEX "plans_employee_id_idx" ON "plans" USING btree ("employee_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "plan_destinations_plan_customer_unique" ON "plan_destinations" USING btree ("plan_id","customer_id");--> statement-breakpoint
|
||||
CREATE INDEX "plan_destinations_plan_id_idx" ON "plan_destinations" USING btree ("plan_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "plan_invoices_plan_invoice_unique" ON "plan_invoices" USING btree ("plan_id","invoice_id");--> statement-breakpoint
|
||||
CREATE INDEX "plan_invoices_plan_id_idx" ON "plan_invoices" USING btree ("plan_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "plan_packing_slips_plan_slip_unique" ON "plan_packing_slips" USING btree ("plan_id","packing_slip_id");--> statement-breakpoint
|
||||
CREATE INDEX "plan_packing_slips_plan_id_idx" ON "plan_packing_slips" USING btree ("plan_id");--> statement-breakpoint
|
||||
INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES
|
||||
('CONFIGURATION.SETTING', 'Company settings', 13),
|
||||
('SALES.CYCLE', 'Sales cycles', 14),
|
||||
('SALES.PLAN', 'Sales plans', 15),
|
||||
('LOGISTICS.CYCLE', 'Logistics cycles', 16),
|
||||
('LOGISTICS.PLAN', 'Logistics plans', 17);
|
||||
@@ -0,0 +1,21 @@
|
||||
ALTER TABLE "users" ADD COLUMN "status" text DEFAULT 'draft' NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "created_by" uuid;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "updated_by" uuid;
|
||||
--> statement-breakpoint
|
||||
UPDATE "users" SET "status" = 'active', "created_by" = "id", "updated_by" = "id";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "users" ALTER COLUMN "created_by" SET NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "users" ALTER COLUMN "updated_by" SET NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD CONSTRAINT "users_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD CONSTRAINT "users_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "employees" ADD COLUMN "user_id" uuid;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "employees" ADD CONSTRAINT "employees_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "employees_user_id_unique" ON "employees" USING btree ("user_id");
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,55 @@
|
||||
"when": 1787549883658,
|
||||
"tag": "0005_past_vengeance",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1787554000000,
|
||||
"tag": "0006_customers",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1787555000000,
|
||||
"tag": "0007_employees",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1787556000000,
|
||||
"tag": "0008_products",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1787557000000,
|
||||
"tag": "0009_sales",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1787558000000,
|
||||
"tag": "0010_phase2_shape",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1787559000000,
|
||||
"tag": "0011_field",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1787560000000,
|
||||
"tag": "0012_users_primary",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import loadEnv from './config/env';
|
||||
import { DatabaseModule } from './database/database.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { ConfigurationModule } from './modules/configuration/configuration.module';
|
||||
import { FieldModule } from './modules/field/field.module';
|
||||
import { SalesModule } from './modules/sales/sales.module';
|
||||
import { PrivilegesModule } from './modules/privileges/privileges.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
|
||||
@@ -20,6 +22,8 @@ import { UsersModule } from './modules/users/users.module';
|
||||
AuthModule,
|
||||
PrivilegesModule,
|
||||
ConfigurationModule,
|
||||
SalesModule,
|
||||
FieldModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
||||
@@ -22,3 +22,28 @@ export {
|
||||
PAGINATION_DEFAULT_LIMIT,
|
||||
PAGINATION_MAX_LIMIT,
|
||||
} from './pagination.constants';
|
||||
export {
|
||||
CODE_RELATION_FIELDS,
|
||||
DEFAULT_RELATION_FIELDS,
|
||||
fallbackUserRelation,
|
||||
pickCodeRelation,
|
||||
pickDefaultRelation,
|
||||
pickRelation,
|
||||
pickUserRelation,
|
||||
USER_RELATION_FIELDS,
|
||||
type CodeRelation,
|
||||
type DefaultRelation,
|
||||
type UserRelation,
|
||||
} from './relation-fields';
|
||||
export {
|
||||
CodeRelationDto,
|
||||
DefaultRelationDto,
|
||||
UserRelationDto,
|
||||
} from './relation.dto';
|
||||
export {
|
||||
ORDER_TYPES,
|
||||
toOrderClauses,
|
||||
type ListOrderQuery,
|
||||
type OrderDefault,
|
||||
type OrderType,
|
||||
} from './order-clause';
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { asc, desc } from 'drizzle-orm';
|
||||
import { integer, pgTable } from 'drizzle-orm/pg-core';
|
||||
import { toOrderClauses } from './order-clause';
|
||||
|
||||
const sample = pgTable('sample', {
|
||||
code: integer('code'),
|
||||
name: integer('name'),
|
||||
createdAt: integer('created_at'),
|
||||
});
|
||||
|
||||
const columns = {
|
||||
code: sample.code,
|
||||
name: sample.name,
|
||||
createdAt: sample.createdAt,
|
||||
};
|
||||
|
||||
describe('toOrderClauses', () => {
|
||||
it('uses default columns when orderBy is omitted', () => {
|
||||
expect(
|
||||
toOrderClauses(columns, {}, [{ column: 'code', type: 'ASC' }]),
|
||||
).toEqual([asc(sample.code)]);
|
||||
});
|
||||
|
||||
it('applies multiple defaults when orderBy is omitted', () => {
|
||||
expect(
|
||||
toOrderClauses(columns, {}, [
|
||||
{ column: 'createdAt', type: 'ASC' },
|
||||
{ column: 'code', type: 'ASC' },
|
||||
]),
|
||||
).toEqual([asc(sample.createdAt), asc(sample.code)]);
|
||||
});
|
||||
|
||||
it('uses a single client column and DESC', () => {
|
||||
expect(
|
||||
toOrderClauses(columns, { orderBy: 'name', orderType: 'DESC' }, [
|
||||
{ column: 'code', type: 'ASC' },
|
||||
]),
|
||||
).toEqual([desc(sample.name)]);
|
||||
});
|
||||
|
||||
it('applies orderType to defaults when orderBy is omitted', () => {
|
||||
expect(
|
||||
toOrderClauses(columns, { orderType: 'DESC' }, [
|
||||
{ column: 'code', type: 'ASC' },
|
||||
]),
|
||||
).toEqual([desc(sample.code)]);
|
||||
});
|
||||
|
||||
it('rejects unknown orderBy without echoing the raw value as SQL', () => {
|
||||
expect(() =>
|
||||
toOrderClauses(columns, { orderBy: 'drop table' }, [{ column: 'code' }]),
|
||||
).toThrow(BadRequestException);
|
||||
try {
|
||||
toOrderClauses(columns, { orderBy: 'drop table' }, [{ column: 'code' }]);
|
||||
} catch (error) {
|
||||
expect((error as BadRequestException).message).toContain(
|
||||
'code, name, createdAt',
|
||||
);
|
||||
expect((error as BadRequestException).message).not.toContain(
|
||||
'drop table',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid orderType', () => {
|
||||
expect(() =>
|
||||
toOrderClauses(columns, { orderType: 'SIDEWAYS' }, [{ column: 'code' }]),
|
||||
).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { asc, desc, type SQL } from 'drizzle-orm';
|
||||
|
||||
export const ORDER_TYPES = ['ASC', 'DESC'] as const;
|
||||
export type OrderType = (typeof ORDER_TYPES)[number];
|
||||
|
||||
export type ListOrderQuery = {
|
||||
readonly orderBy?: string;
|
||||
readonly orderType?: string;
|
||||
};
|
||||
|
||||
export type OrderDefault = {
|
||||
readonly column?: string;
|
||||
readonly type?: OrderType;
|
||||
};
|
||||
|
||||
export function toOrderClauses(
|
||||
columns: Record<string, Parameters<typeof asc>[0]>,
|
||||
query: ListOrderQuery,
|
||||
defaults: readonly OrderDefault[],
|
||||
): SQL[] {
|
||||
if (query.orderBy) {
|
||||
return [toSql(columns, query.orderBy, normalizeOrderType(query.orderType))];
|
||||
}
|
||||
const typeOverride =
|
||||
query.orderType != null && query.orderType !== ''
|
||||
? normalizeOrderType(query.orderType)
|
||||
: undefined;
|
||||
return defaults.map((entry) =>
|
||||
toSql(columns, entry.column ?? '', typeOverride ?? entry.type ?? 'ASC'),
|
||||
);
|
||||
}
|
||||
|
||||
function toSql(
|
||||
columns: Record<string, Parameters<typeof asc>[0]>,
|
||||
column: string,
|
||||
type: OrderType,
|
||||
): SQL {
|
||||
const selected = columns[column];
|
||||
if (selected == null) {
|
||||
throw new BadRequestException(
|
||||
`Invalid orderBy. Allowed: ${Object.keys(columns).join(', ')}`,
|
||||
);
|
||||
}
|
||||
return type === 'DESC' ? desc(selected) : asc(selected);
|
||||
}
|
||||
|
||||
function normalizeOrderType(raw?: string): OrderType {
|
||||
if (raw == null || raw === '') {
|
||||
return 'ASC';
|
||||
}
|
||||
const normalized = raw.toUpperCase();
|
||||
if (normalized === 'ASC' || normalized === 'DESC') {
|
||||
return normalized;
|
||||
}
|
||||
throw new BadRequestException('Invalid orderType. Allowed: ASC, DESC');
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import { ORDER_TYPES } from './order-clause';
|
||||
import { PAGINATION_MAX_LIMIT } from './pagination.constants';
|
||||
|
||||
export class PaginationQueryDto {
|
||||
@@ -21,4 +23,19 @@ export class PaginationQueryDto {
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
offset?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Column to order by (resource response field name)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
orderBy?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ORDER_TYPES, default: 'ASC' })
|
||||
@IsOptional()
|
||||
@Transform(({ value }: { value: unknown }) =>
|
||||
typeof value === 'string' ? value.toUpperCase() : value,
|
||||
)
|
||||
@IsIn([...ORDER_TYPES])
|
||||
orderType?: (typeof ORDER_TYPES)[number];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
CODE_RELATION_FIELDS,
|
||||
DEFAULT_RELATION_FIELDS,
|
||||
pickCodeRelation,
|
||||
pickDefaultRelation,
|
||||
pickRelation,
|
||||
pickUserRelation,
|
||||
USER_RELATION_FIELDS,
|
||||
} from './relation-fields';
|
||||
|
||||
describe('pickRelation', () => {
|
||||
const catalog = {
|
||||
id: 'div-1',
|
||||
code: 'JKT',
|
||||
name: 'Jakarta',
|
||||
status: 'active',
|
||||
extra: 'secret',
|
||||
};
|
||||
|
||||
const user = {
|
||||
id: 'user-1',
|
||||
username: 'admin',
|
||||
passwordHash: 'hashed',
|
||||
};
|
||||
|
||||
it('picks default id, code, name fields', () => {
|
||||
expect(pickRelation(catalog, DEFAULT_RELATION_FIELDS)).toEqual({
|
||||
id: 'div-1',
|
||||
code: 'JKT',
|
||||
name: 'Jakarta',
|
||||
});
|
||||
});
|
||||
|
||||
it('picks a custom field list for users', () => {
|
||||
expect(pickRelation(user, USER_RELATION_FIELDS)).toEqual({
|
||||
id: 'user-1',
|
||||
username: 'admin',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for null or undefined sources', () => {
|
||||
const missing: typeof catalog | null = null;
|
||||
const unset: typeof catalog | undefined = undefined;
|
||||
expect(
|
||||
pickRelation<typeof catalog, (typeof DEFAULT_RELATION_FIELDS)[number]>(
|
||||
missing,
|
||||
DEFAULT_RELATION_FIELDS,
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
pickRelation<typeof catalog, (typeof DEFAULT_RELATION_FIELDS)[number]>(
|
||||
unset,
|
||||
DEFAULT_RELATION_FIELDS,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('does not copy fields outside the list', () => {
|
||||
const picked = pickRelation(catalog, DEFAULT_RELATION_FIELDS);
|
||||
expect(picked).not.toHaveProperty('status');
|
||||
expect(picked).not.toHaveProperty('extra');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickCodeRelation', () => {
|
||||
it('picks id and code only', () => {
|
||||
expect(
|
||||
pickCodeRelation({
|
||||
id: 'so-1',
|
||||
code: 'SO-001',
|
||||
}),
|
||||
).toEqual({ id: 'so-1', code: 'SO-001' });
|
||||
expect(CODE_RELATION_FIELDS).toEqual(['id', 'code']);
|
||||
expect(pickCodeRelation(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickDefaultRelation / pickUserRelation', () => {
|
||||
it('maps catalog and user sources without leaking extra fields', () => {
|
||||
expect(
|
||||
pickDefaultRelation({
|
||||
id: 'div-1',
|
||||
code: 'JKT',
|
||||
name: 'Jakarta',
|
||||
}),
|
||||
).toEqual({ id: 'div-1', code: 'JKT', name: 'Jakarta' });
|
||||
expect(pickDefaultRelation(null)).toBeNull();
|
||||
expect(pickUserRelation({ id: 'user-1', username: 'admin' })).toEqual({
|
||||
id: 'user-1',
|
||||
username: 'admin',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
export const DEFAULT_RELATION_FIELDS = ['id', 'code', 'name'] as const;
|
||||
export const USER_RELATION_FIELDS = ['id', 'username'] as const;
|
||||
export const CODE_RELATION_FIELDS = ['id', 'code'] as const;
|
||||
|
||||
export type DefaultRelation = {
|
||||
readonly id: string;
|
||||
readonly code: string;
|
||||
readonly name: string;
|
||||
};
|
||||
|
||||
export type UserRelation = {
|
||||
readonly id: string;
|
||||
readonly username: string;
|
||||
};
|
||||
|
||||
export type CodeRelation = {
|
||||
readonly id: string;
|
||||
readonly code: string;
|
||||
};
|
||||
|
||||
export function pickRelation<T, K extends keyof NonNullable<T>>(
|
||||
source: T | null | undefined,
|
||||
fields: readonly K[],
|
||||
): Pick<NonNullable<T>, K> | null {
|
||||
if (source == null) {
|
||||
return null;
|
||||
}
|
||||
const result = {} as Pick<NonNullable<T>, K>;
|
||||
for (const field of fields) {
|
||||
result[field] = source[field];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function pickDefaultRelation(
|
||||
source: DefaultRelation | null | undefined,
|
||||
): DefaultRelation | null {
|
||||
return pickRelation(source, DEFAULT_RELATION_FIELDS);
|
||||
}
|
||||
|
||||
export function pickUserRelation(source: UserRelation): UserRelation {
|
||||
return (
|
||||
pickRelation(source, USER_RELATION_FIELDS) ?? {
|
||||
id: source.id,
|
||||
username: source.username,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function pickCodeRelation(
|
||||
source: CodeRelation | null | undefined,
|
||||
): CodeRelation | null {
|
||||
return pickRelation(source, CODE_RELATION_FIELDS);
|
||||
}
|
||||
|
||||
export function fallbackUserRelation(
|
||||
source: UserRelation | null | undefined,
|
||||
fallbackId: string,
|
||||
): UserRelation {
|
||||
return pickUserRelation(source ?? { id: fallbackId, username: '' });
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class DefaultRelationDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
code!: string;
|
||||
|
||||
@ApiProperty()
|
||||
name!: string;
|
||||
}
|
||||
|
||||
export class UserRelationDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
username!: string;
|
||||
}
|
||||
|
||||
export class CodeRelationDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
code!: string;
|
||||
}
|
||||
@@ -92,8 +92,24 @@ describe('DateTime', () => {
|
||||
expect(dt.value).toBe(Date.UTC(2026, 7, 20, 10, 0, 0, 0));
|
||||
});
|
||||
|
||||
it('rejects date-only strings', () => {
|
||||
expect(() => DateTime.create('2026-08-20')).toThrow(InvalidDateTimeError);
|
||||
it('parses date-only strings as start of day in DEFAULT_TIMEZONE (GMT+7)', () => {
|
||||
delete process.env.DEFAULT_TIMEZONE;
|
||||
|
||||
const dt = DateTime.create('2026-08-20');
|
||||
|
||||
expect(dt.value).toBe(Date.UTC(2026, 7, 19, 17, 0, 0, 0));
|
||||
});
|
||||
|
||||
it('parses date-only strings using a custom DEFAULT_TIMEZONE', () => {
|
||||
process.env.DEFAULT_TIMEZONE = 'UTC+0';
|
||||
|
||||
const dt = DateTime.create('2026-08-20');
|
||||
|
||||
expect(dt.value).toBe(Date.UTC(2026, 7, 20, 0, 0, 0, 0));
|
||||
});
|
||||
|
||||
it('rejects invalid calendar dates on date-only strings', () => {
|
||||
expect(() => DateTime.create('2026-02-30')).toThrow(InvalidDateTimeError);
|
||||
});
|
||||
|
||||
it('rejects empty string', () => {
|
||||
@@ -299,4 +315,82 @@ describe('DateTime', () => {
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('startOfDay', () => {
|
||||
it('returns midnight of the same calendar day in DEFAULT_TIMEZONE', () => {
|
||||
delete process.env.DEFAULT_TIMEZONE;
|
||||
const dt = DateTime.create('2026-08-20T17:30:00+07:00');
|
||||
|
||||
const start = dt.startOfDay();
|
||||
|
||||
expect(start.value).toBe(Date.UTC(2026, 7, 19, 17, 0, 0, 0));
|
||||
expect(start.equals(DateTime.create('2026-08-20'))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not mutate the original instant', () => {
|
||||
const dt = DateTime.create('2026-08-20T10:00:00Z');
|
||||
const before = dt.value;
|
||||
|
||||
dt.startOfDay();
|
||||
|
||||
expect(dt.value).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('weekdayName', () => {
|
||||
it('returns monday through sunday in DEFAULT_TIMEZONE', () => {
|
||||
delete process.env.DEFAULT_TIMEZONE;
|
||||
|
||||
expect(DateTime.create('2026-08-24').weekdayName()).toBe('monday');
|
||||
expect(DateTime.create('2026-08-25').weekdayName()).toBe('tuesday');
|
||||
expect(DateTime.create('2026-08-26').weekdayName()).toBe('wednesday');
|
||||
expect(DateTime.create('2026-08-27').weekdayName()).toBe('thursday');
|
||||
expect(DateTime.create('2026-08-28').weekdayName()).toBe('friday');
|
||||
expect(DateTime.create('2026-08-29').weekdayName()).toBe('saturday');
|
||||
expect(DateTime.create('2026-08-30').weekdayName()).toBe('sunday');
|
||||
});
|
||||
|
||||
it('uses the calendar day in DEFAULT_TIMEZONE, not UTC', () => {
|
||||
delete process.env.DEFAULT_TIMEZONE;
|
||||
// 2026-08-24 00:30 GMT+7 is still Sunday UTC
|
||||
const dt = DateTime.create('2026-08-24T00:30:00+07:00');
|
||||
|
||||
expect(dt.weekdayName()).toBe('monday');
|
||||
});
|
||||
});
|
||||
|
||||
describe('wholeWeeksSince', () => {
|
||||
it('returns 0 on the epoch day and through the next 6 days', () => {
|
||||
delete process.env.DEFAULT_TIMEZONE;
|
||||
const epoch = DateTime.create('2026-01-05');
|
||||
|
||||
expect(DateTime.create('2026-01-05').wholeWeeksSince(epoch)).toBe(0);
|
||||
expect(DateTime.create('2026-01-11').wholeWeeksSince(epoch)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 1 at +7 days (next week)', () => {
|
||||
delete process.env.DEFAULT_TIMEZONE;
|
||||
const epoch = DateTime.create('2026-01-05');
|
||||
|
||||
expect(DateTime.create('2026-01-12').wholeWeeksSince(epoch)).toBe(1);
|
||||
});
|
||||
|
||||
it('wraps so remainder 0 of a 1-based week index is the last cycle', () => {
|
||||
delete process.env.DEFAULT_TIMEZONE;
|
||||
const epoch = DateTime.create('2026-01-05');
|
||||
const totalCycles = 3;
|
||||
const week2 = DateTime.create('2026-01-19'); // wholeWeeks = 2
|
||||
const cycleNumber = (week2.wholeWeeksSince(epoch) % totalCycles) + 1;
|
||||
|
||||
expect(week2.wholeWeeksSince(epoch)).toBe(2);
|
||||
expect(cycleNumber).toBe(3);
|
||||
});
|
||||
|
||||
it('returns a negative count when the date is before the epoch', () => {
|
||||
delete process.env.DEFAULT_TIMEZONE;
|
||||
const epoch = DateTime.create('2026-01-05');
|
||||
|
||||
expect(DateTime.create('2025-12-29').wholeWeeksSince(epoch)).toBe(-1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,24 @@ const MAX_UNIX_MS = 8_640_000_000_000_000;
|
||||
const ISO_DATETIME =
|
||||
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(?:(Z)|([+-])(\d{2})(?::?(\d{2}))?)?$/i;
|
||||
|
||||
/** Calendar date (YYYY-MM-DD), interpreted as 00:00:00 in DEFAULT_TIMEZONE. */
|
||||
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||
|
||||
const WEEKDAY_NAMES = [
|
||||
'sunday',
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
] as const;
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
const MS_PER_WEEK = 7 * MS_PER_DAY;
|
||||
|
||||
export type WeekdayName = (typeof WEEKDAY_NAMES)[number];
|
||||
|
||||
/** Fixed offset forms: GMT+7, UTC+07:00, +7, +07:00, +0700, Z, UTC */
|
||||
const TZ_OFFSET =
|
||||
/^(?:(?:GMT|UTC)\s*)?([+-])(\d{1,2})(?::?(\d{2}))?$|^(?:Z|UTC|GMT)$/i;
|
||||
@@ -149,6 +167,20 @@ export class DateTime {
|
||||
}
|
||||
|
||||
const trimmed = raw.trim();
|
||||
const dateOnly = ISO_DATE.exec(trimmed);
|
||||
if (dateOnly) {
|
||||
return DateTime.fromParts(
|
||||
Number.parseInt(dateOnly[1], 10),
|
||||
Number.parseInt(dateOnly[2], 10),
|
||||
Number.parseInt(dateOnly[3], 10),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
resolveDefaultOffsetMinutes(),
|
||||
);
|
||||
}
|
||||
|
||||
const match = ISO_DATETIME.exec(trimmed);
|
||||
if (!match) {
|
||||
throw new InvalidDateTimeError();
|
||||
@@ -175,7 +207,7 @@ export class DateTime {
|
||||
offsetMinutes = resolveDefaultOffsetMinutes();
|
||||
}
|
||||
|
||||
const utcMs = utcMsFromParts(
|
||||
return DateTime.fromParts(
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
@@ -185,8 +217,6 @@ export class DateTime {
|
||||
ms,
|
||||
offsetMinutes,
|
||||
);
|
||||
|
||||
return new DateTime(utcMs, DateTime.createToken);
|
||||
}
|
||||
|
||||
static fromUnixMs(ms: number): DateTime {
|
||||
@@ -217,6 +247,32 @@ export class DateTime {
|
||||
return formatInOffset(this.unixMs, offsetMinutes);
|
||||
}
|
||||
|
||||
startOfDay(): DateTime {
|
||||
const offsetMinutes = resolveDefaultOffsetMinutes();
|
||||
const shifted = new Date(this.unixMs + offsetMinutes * 60_000);
|
||||
return DateTime.fromParts(
|
||||
shifted.getUTCFullYear(),
|
||||
shifted.getUTCMonth() + 1,
|
||||
shifted.getUTCDate(),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
offsetMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
weekdayName(): WeekdayName {
|
||||
const offsetMinutes = resolveDefaultOffsetMinutes();
|
||||
const shifted = new Date(this.unixMs + offsetMinutes * 60_000);
|
||||
return WEEKDAY_NAMES[shifted.getUTCDay()];
|
||||
}
|
||||
|
||||
wholeWeeksSince(epoch: DateTime): number {
|
||||
const diff = this.startOfDay().value - epoch.startOfDay().value;
|
||||
return Math.trunc(diff / MS_PER_WEEK);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.format();
|
||||
}
|
||||
@@ -224,4 +280,27 @@ export class DateTime {
|
||||
toJSON(): number {
|
||||
return this.unixMs;
|
||||
}
|
||||
|
||||
private static fromParts(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
hour: number,
|
||||
minute: number,
|
||||
second: number,
|
||||
ms: number,
|
||||
offsetMinutes: number,
|
||||
): DateTime {
|
||||
const utcMs = utcMsFromParts(
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
hour,
|
||||
minute,
|
||||
second,
|
||||
ms,
|
||||
offsetMinutes,
|
||||
);
|
||||
return new DateTime(utcMs, DateTime.createToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Decimal } from './decimal';
|
||||
import { InvalidDecimalError } from './invalid-decimal.error';
|
||||
|
||||
describe('Decimal', () => {
|
||||
describe('create', () => {
|
||||
it('accepts canonical scale-4 strings', () => {
|
||||
expect(Decimal.create('10.5000').value).toBe('10.5000');
|
||||
expect(Decimal.create('0').value).toBe('0.0000');
|
||||
expect(Decimal.create('0.5').value).toBe('0.5000');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace', () => {
|
||||
expect(Decimal.create(' 12.34 ').value).toBe('12.3400');
|
||||
});
|
||||
|
||||
it('accepts integer-looking numbers at the HTTP edge', () => {
|
||||
expect(Decimal.create(10).value).toBe('10.0000');
|
||||
expect(Decimal.create(0).value).toBe('0.0000');
|
||||
});
|
||||
|
||||
it('accepts a leading plus or minus', () => {
|
||||
expect(Decimal.create('+2.5').value).toBe('2.5000');
|
||||
expect(Decimal.create('-2.5').value).toBe('-2.5000');
|
||||
});
|
||||
|
||||
it('rejects more than 4 fractional digits', () => {
|
||||
expect(() => Decimal.create('1.23456')).toThrow(InvalidDecimalError);
|
||||
});
|
||||
|
||||
it('rejects non-finite numbers and non-numeric strings', () => {
|
||||
expect(() => Decimal.create(Number.NaN)).toThrow(InvalidDecimalError);
|
||||
expect(() => Decimal.create(Number.POSITIVE_INFINITY)).toThrow(
|
||||
InvalidDecimalError,
|
||||
);
|
||||
expect(() => Decimal.create('abc')).toThrow(InvalidDecimalError);
|
||||
expect(() => Decimal.create('')).toThrow(InvalidDecimalError);
|
||||
expect(() => Decimal.create(' ')).toThrow(InvalidDecimalError);
|
||||
expect(() => Decimal.create('1e3')).toThrow(InvalidDecimalError);
|
||||
});
|
||||
|
||||
it('rejects values that exceed precision 18', () => {
|
||||
expect(() => Decimal.create('123456789012345.0000')).toThrow(
|
||||
InvalidDecimalError,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not echo raw input in the error message', () => {
|
||||
expect(() => Decimal.create('secret-1.23')).toThrow('Invalid decimal');
|
||||
try {
|
||||
Decimal.create('secret-1.23');
|
||||
} catch (error) {
|
||||
expect((error as Error).message).not.toContain('secret-1.23');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('arithmetic', () => {
|
||||
it('adds, subtracts, and multiplies at scale 4', () => {
|
||||
const a = Decimal.create('2.5000');
|
||||
const b = Decimal.create('1.2500');
|
||||
expect(a.add(b).value).toBe('3.7500');
|
||||
expect(a.subtract(b).value).toBe('1.2500');
|
||||
expect(a.multiply(b).value).toBe('3.1250');
|
||||
});
|
||||
|
||||
it('compares values', () => {
|
||||
const a = Decimal.create('1.0000');
|
||||
const b = Decimal.create('2.0000');
|
||||
expect(a.compare(b)).toBe(-1);
|
||||
expect(b.compare(a)).toBe(1);
|
||||
expect(a.compare(Decimal.create('1'))).toBe(0);
|
||||
expect(a.equals(Decimal.create('1.0000'))).toBe(true);
|
||||
expect(a.equals(b)).toBe(false);
|
||||
expect(Decimal.create('0').isZero()).toBe(true);
|
||||
expect(Decimal.create('-1').isNegative()).toBe(true);
|
||||
expect(Decimal.create('0.0001').isPositive()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serialization', () => {
|
||||
it('toString and toJSON return the canonical string', () => {
|
||||
const value = Decimal.create('9.1');
|
||||
expect(value.toString()).toBe('9.1000');
|
||||
expect(value.toJSON()).toBe('9.1000');
|
||||
expect(JSON.stringify({ price: value })).toBe('{"price":"9.1000"}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('construction', () => {
|
||||
it('cannot be constructed with new Decimal()', () => {
|
||||
expect(
|
||||
() => new (Decimal as unknown as new (...args: unknown[]) => Decimal)(),
|
||||
).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { InvalidDecimalError } from './invalid-decimal.error';
|
||||
|
||||
export const DECIMAL_SCALE = 4;
|
||||
export const DECIMAL_PRECISION = 18;
|
||||
const DECIMAL_FACTOR = 10n ** BigInt(DECIMAL_SCALE);
|
||||
const MAX_UNSCALED =
|
||||
10n ** BigInt(DECIMAL_PRECISION) - 1n; /* 18 digits of unscaled integer */
|
||||
|
||||
const DECIMAL_PATTERN = /^[+-]?(?:\d+|\d+\.\d{1,4}|\.\d{1,4})$/;
|
||||
|
||||
export class Decimal {
|
||||
private static readonly createToken = Symbol('Decimal.create');
|
||||
|
||||
private constructor(
|
||||
private readonly unscaled: bigint,
|
||||
token: symbol,
|
||||
) {
|
||||
if (token !== Decimal.createToken) {
|
||||
throw new TypeError('Decimal can only be created via Decimal.create()');
|
||||
}
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Decimal from a string or an integer-looking number.
|
||||
* Canonical scale is 4 (e.g. 10.5 → 10.5000). Precision is 18.
|
||||
*/
|
||||
static create(raw: string | number): Decimal {
|
||||
const text = Decimal.normalizeRaw(raw);
|
||||
if (!DECIMAL_PATTERN.test(text)) {
|
||||
throw new InvalidDecimalError();
|
||||
}
|
||||
|
||||
const negative = text.startsWith('-');
|
||||
const unsigned =
|
||||
text.startsWith('+') || text.startsWith('-') ? text.slice(1) : text;
|
||||
const [wholePart, fractionPart = ''] = unsigned.split('.');
|
||||
const whole = wholePart === '' ? '0' : wholePart;
|
||||
const fraction = fractionPart.padEnd(DECIMAL_SCALE, '0');
|
||||
const digits = `${whole}${fraction}`.replace(/^0+(?=\d)/, '');
|
||||
let unscaled = BigInt(digits);
|
||||
if (negative) {
|
||||
unscaled = -unscaled;
|
||||
}
|
||||
if (unscaled > MAX_UNSCALED || unscaled < -MAX_UNSCALED) {
|
||||
throw new InvalidDecimalError();
|
||||
}
|
||||
return new Decimal(unscaled, Decimal.createToken);
|
||||
}
|
||||
|
||||
static zero(): Decimal {
|
||||
return new Decimal(0n, Decimal.createToken);
|
||||
}
|
||||
|
||||
get value(): string {
|
||||
return this.format();
|
||||
}
|
||||
|
||||
add(other: Decimal): Decimal {
|
||||
return Decimal.fromUnscaled(this.unscaled + other.unscaled);
|
||||
}
|
||||
|
||||
subtract(other: Decimal): Decimal {
|
||||
return Decimal.fromUnscaled(this.unscaled - other.unscaled);
|
||||
}
|
||||
|
||||
multiply(other: Decimal): Decimal {
|
||||
const product = this.unscaled * other.unscaled;
|
||||
const half = DECIMAL_FACTOR / 2n;
|
||||
const remainder = product % DECIMAL_FACTOR;
|
||||
let quotient = product / DECIMAL_FACTOR;
|
||||
const absRemainder = remainder < 0n ? -remainder : remainder;
|
||||
if (absRemainder >= half) {
|
||||
quotient += product < 0n ? -1n : 1n;
|
||||
}
|
||||
return Decimal.fromUnscaled(quotient);
|
||||
}
|
||||
|
||||
compare(other: Decimal): -1 | 0 | 1 {
|
||||
if (this.unscaled < other.unscaled) {
|
||||
return -1;
|
||||
}
|
||||
if (this.unscaled > other.unscaled) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
equals(other: Decimal): boolean {
|
||||
return other instanceof Decimal && this.unscaled === other.unscaled;
|
||||
}
|
||||
|
||||
isZero(): boolean {
|
||||
return this.unscaled === 0n;
|
||||
}
|
||||
|
||||
isNegative(): boolean {
|
||||
return this.unscaled < 0n;
|
||||
}
|
||||
|
||||
isPositive(): boolean {
|
||||
return this.unscaled > 0n;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.format();
|
||||
}
|
||||
|
||||
toJSON(): string {
|
||||
return this.format();
|
||||
}
|
||||
|
||||
private format(): string {
|
||||
const negative = this.unscaled < 0n;
|
||||
const abs = negative ? -this.unscaled : this.unscaled;
|
||||
const padded = abs.toString().padStart(DECIMAL_SCALE + 1, '0');
|
||||
const whole = padded.slice(0, -DECIMAL_SCALE);
|
||||
const fraction = padded.slice(-DECIMAL_SCALE);
|
||||
return `${negative ? '-' : ''}${whole}.${fraction}`;
|
||||
}
|
||||
|
||||
private static fromUnscaled(unscaled: bigint): Decimal {
|
||||
if (unscaled > MAX_UNSCALED || unscaled < -MAX_UNSCALED) {
|
||||
throw new InvalidDecimalError();
|
||||
}
|
||||
return new Decimal(unscaled, Decimal.createToken);
|
||||
}
|
||||
|
||||
private static normalizeRaw(raw: string | number): string {
|
||||
if (typeof raw === 'number') {
|
||||
if (!Number.isInteger(raw) || !Number.isSafeInteger(raw)) {
|
||||
throw new InvalidDecimalError();
|
||||
}
|
||||
return String(raw);
|
||||
}
|
||||
if (typeof raw !== 'string') {
|
||||
throw new InvalidDecimalError();
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === '') {
|
||||
throw new InvalidDecimalError();
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class InvalidDecimalError extends Error {
|
||||
constructor() {
|
||||
super('Invalid decimal');
|
||||
this.name = 'InvalidDecimalError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { bigint, pgTable, uuid } from 'drizzle-orm/pg-core';
|
||||
import { primaryEntityColumns } from './primary-entity-columns';
|
||||
import { users } from './schema';
|
||||
|
||||
/**
|
||||
* Company-wide operational settings (singleton aggregate).
|
||||
*/
|
||||
export const companySettings = pgTable('company_settings', {
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
cycleStartDate: bigint('cycle_start_date', { mode: 'number' }).notNull(),
|
||||
...primaryEntityColumns(users),
|
||||
});
|
||||
|
||||
export type CompanySettingsRow = typeof companySettings.$inferSelect;
|
||||
export type NewCompanySettingsRow = typeof companySettings.$inferInsert;
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
doublePrecision,
|
||||
index,
|
||||
pgTable,
|
||||
text,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
varchar,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { primaryEntityColumns } from './primary-entity-columns';
|
||||
import { users } from './schema';
|
||||
|
||||
/**
|
||||
* Customers (primary aggregate).
|
||||
* Kept in a separate module so Drizzle's table type stays resolvable.
|
||||
*/
|
||||
export const customers = pgTable(
|
||||
'customers',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
code: varchar('code', { length: 16 }).notNull(),
|
||||
name: varchar('name', { length: 64 }).notNull(),
|
||||
phone: text('phone').notNull(),
|
||||
address: text('address').notNull(),
|
||||
latitude: doublePrecision('latitude'),
|
||||
longitude: doublePrecision('longitude'),
|
||||
nfcId: text('nfc_id'),
|
||||
...primaryEntityColumns(users),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('customers_code_unique').on(t.code),
|
||||
uniqueIndex('customers_nfc_id_unique').on(t.nfcId),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Customer contacts (child rows). Cascade with the parent customer.
|
||||
*/
|
||||
export const customerContacts = pgTable(
|
||||
'customer_contacts',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
customerId: uuid('customer_id')
|
||||
.notNull()
|
||||
.references(() => customers.id, { onDelete: 'cascade' }),
|
||||
name: varchar('name', { length: 64 }).notNull(),
|
||||
jobTitle: varchar('job_title', { length: 64 }),
|
||||
phone: text('phone'),
|
||||
mobilePhone: text('mobile_phone'),
|
||||
notes: text('notes'),
|
||||
},
|
||||
(t) => [index('customer_contacts_customer_id_idx').on(t.customerId)],
|
||||
);
|
||||
|
||||
export type CustomerRow = typeof customers.$inferSelect;
|
||||
export type NewCustomerRow = typeof customers.$inferInsert;
|
||||
export type CustomerContactRow = typeof customerContacts.$inferSelect;
|
||||
export type NewCustomerContactRow = typeof customerContacts.$inferInsert;
|
||||
@@ -0,0 +1,83 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { branches } from './branches-table';
|
||||
import { customers } from './customers-table';
|
||||
import { employees } from './employees-table';
|
||||
import { primaryEntityColumns } from './primary-entity-columns';
|
||||
import { users } from './schema';
|
||||
|
||||
export type StoredRouteGeometry = {
|
||||
readonly type: 'LineString';
|
||||
readonly coordinates: readonly (readonly [number, number])[];
|
||||
};
|
||||
|
||||
export const cycles = pgTable(
|
||||
'cycles',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
employeeId: uuid('employee_id')
|
||||
.notNull()
|
||||
.references(() => employees.id, { onDelete: 'restrict' }),
|
||||
purpose: text('purpose').notNull(),
|
||||
cycleNumber: integer('cycle_number').notNull(),
|
||||
...primaryEntityColumns(users),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('cycles_employee_purpose_number_live_unique')
|
||||
.on(t.employeeId, t.purpose, t.cycleNumber)
|
||||
.where(sql`${t.status} <> 'archived'`),
|
||||
index('cycles_employee_id_idx').on(t.employeeId),
|
||||
],
|
||||
);
|
||||
|
||||
export const cycleWeekdays = pgTable(
|
||||
'cycle_weekdays',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
cycleId: uuid('cycle_id')
|
||||
.notNull()
|
||||
.references(() => cycles.id, { onDelete: 'cascade' }),
|
||||
weekday: text('weekday').notNull(),
|
||||
startBranchId: uuid('start_branch_id')
|
||||
.notNull()
|
||||
.references(() => branches.id, { onDelete: 'restrict' }),
|
||||
endBranchId: uuid('end_branch_id')
|
||||
.notNull()
|
||||
.references(() => branches.id, { onDelete: 'restrict' }),
|
||||
routeGeometry: jsonb('route_geometry')
|
||||
.$type<StoredRouteGeometry>()
|
||||
.notNull(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('cycle_weekdays_cycle_weekday_unique').on(t.cycleId, t.weekday),
|
||||
index('cycle_weekdays_cycle_id_idx').on(t.cycleId),
|
||||
],
|
||||
);
|
||||
|
||||
export const cycleDestinations = pgTable(
|
||||
'cycle_destinations',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
cycleWeekdayId: uuid('cycle_weekday_id')
|
||||
.notNull()
|
||||
.references(() => cycleWeekdays.id, { onDelete: 'cascade' }),
|
||||
customerId: uuid('customer_id')
|
||||
.notNull()
|
||||
.references(() => customers.id, { onDelete: 'restrict' }),
|
||||
sortOrder: integer('sort_order').notNull(),
|
||||
},
|
||||
(t) => [index('cycle_destinations_weekday_id_idx').on(t.cycleWeekdayId)],
|
||||
);
|
||||
|
||||
export type CycleRow = typeof cycles.$inferSelect;
|
||||
export type NewCycleRow = typeof cycles.$inferInsert;
|
||||
export type CycleWeekdayRow = typeof cycleWeekdays.$inferSelect;
|
||||
export type CycleDestinationRow = typeof cycleDestinations.$inferSelect;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { integer, pgTable, primaryKey, varchar } from 'drizzle-orm/pg-core';
|
||||
|
||||
/**
|
||||
* Per-prefix daily counters used to generate document codes.
|
||||
*/
|
||||
export const documentSequences = pgTable(
|
||||
'document_sequences',
|
||||
{
|
||||
prefix: varchar('prefix', { length: 8 }).notNull(),
|
||||
period: varchar('period', { length: 8 }).notNull(),
|
||||
lastValue: integer('last_value').notNull(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.prefix, t.period] })],
|
||||
);
|
||||
|
||||
export type DocumentSequenceRow = typeof documentSequences.$inferSelect;
|
||||
export type NewDocumentSequenceRow = typeof documentSequences.$inferInsert;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { pgTable, text, uniqueIndex, uuid, varchar } from 'drizzle-orm/pg-core';
|
||||
import { primaryEntityColumns } from './primary-entity-columns';
|
||||
import { users } from './schema';
|
||||
|
||||
/**
|
||||
* Employees (primary aggregate).
|
||||
* Kept in a separate module so Drizzle's table type stays resolvable.
|
||||
*/
|
||||
export const employees = pgTable(
|
||||
'employees',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
code: varchar('code', { length: 16 }).notNull(),
|
||||
name: varchar('name', { length: 64 }).notNull(),
|
||||
phone: text('phone').notNull(),
|
||||
position: text('position').notNull(),
|
||||
userId: uuid('user_id').references(() => users.id, {
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
...primaryEntityColumns(users),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('employees_code_unique').on(t.code),
|
||||
uniqueIndex('employees_user_id_unique').on(t.userId),
|
||||
],
|
||||
);
|
||||
|
||||
export type EmployeeRow = typeof employees.$inferSelect;
|
||||
export type NewEmployeeRow = typeof employees.$inferInsert;
|
||||
@@ -0,0 +1,129 @@
|
||||
import { inArray } from 'drizzle-orm';
|
||||
import type { CodeRelation, DefaultRelation } from '../common/http/response';
|
||||
import type { DrizzleDB } from './database.module';
|
||||
import {
|
||||
branches,
|
||||
customers,
|
||||
divisions,
|
||||
employees,
|
||||
packingSlips,
|
||||
products,
|
||||
salesInvoices,
|
||||
salesOrders,
|
||||
salesRequests,
|
||||
} from './schema';
|
||||
|
||||
type CatalogTable =
|
||||
| typeof employees
|
||||
| typeof branches
|
||||
| typeof divisions
|
||||
| typeof customers
|
||||
| typeof products;
|
||||
|
||||
type CodeTable =
|
||||
| typeof salesOrders
|
||||
| typeof salesRequests
|
||||
| typeof packingSlips
|
||||
| typeof salesInvoices;
|
||||
|
||||
async function loadDefaultMap(
|
||||
db: DrizzleDB,
|
||||
table: CatalogTable,
|
||||
ids: readonly string[],
|
||||
): Promise<Map<string, DefaultRelation>> {
|
||||
const unique = [...new Set(ids.filter((id) => id.length > 0))];
|
||||
if (unique.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
const rows = await db
|
||||
.select({ id: table.id, code: table.code, name: table.name })
|
||||
.from(table)
|
||||
.where(inArray(table.id, unique));
|
||||
return new Map(
|
||||
rows.map((row) => [row.id, { id: row.id, code: row.code, name: row.name }]),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadCodeMap(
|
||||
db: DrizzleDB,
|
||||
table: CodeTable,
|
||||
ids: readonly string[],
|
||||
): Promise<Map<string, CodeRelation>> {
|
||||
const unique = [...new Set(ids.filter((id) => id.length > 0))];
|
||||
if (unique.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
const rows = await db
|
||||
.select({ id: table.id, code: table.code })
|
||||
.from(table)
|
||||
.where(inArray(table.id, unique));
|
||||
return new Map(rows.map((row) => [row.id, { id: row.id, code: row.code }]));
|
||||
}
|
||||
|
||||
export function loadEmployeeRelationMap(db: DrizzleDB, ids: readonly string[]) {
|
||||
return loadDefaultMap(db, employees, ids);
|
||||
}
|
||||
|
||||
export function loadBranchRelationMap(db: DrizzleDB, ids: readonly string[]) {
|
||||
return loadDefaultMap(db, branches, ids);
|
||||
}
|
||||
|
||||
export function loadDivisionRelationMap(db: DrizzleDB, ids: readonly string[]) {
|
||||
return loadDefaultMap(db, divisions, ids);
|
||||
}
|
||||
|
||||
export function loadCustomerRelationMap(db: DrizzleDB, ids: readonly string[]) {
|
||||
return loadDefaultMap(db, customers, ids);
|
||||
}
|
||||
|
||||
export function loadProductRelationMap(db: DrizzleDB, ids: readonly string[]) {
|
||||
return loadDefaultMap(db, products, ids);
|
||||
}
|
||||
|
||||
export function loadSalesRequestRelationMap(
|
||||
db: DrizzleDB,
|
||||
ids: readonly string[],
|
||||
) {
|
||||
return loadCodeMap(db, salesRequests, ids);
|
||||
}
|
||||
|
||||
export function loadSalesOrderRelationMap(
|
||||
db: DrizzleDB,
|
||||
ids: readonly string[],
|
||||
) {
|
||||
return loadCodeMap(db, salesOrders, ids);
|
||||
}
|
||||
|
||||
export function loadPackingSlipRelationMap(
|
||||
db: DrizzleDB,
|
||||
ids: readonly string[],
|
||||
) {
|
||||
return loadCodeMap(db, packingSlips, ids);
|
||||
}
|
||||
|
||||
export function loadSalesInvoiceRelationMap(
|
||||
db: DrizzleDB,
|
||||
ids: readonly string[],
|
||||
) {
|
||||
return loadCodeMap(db, salesInvoices, ids);
|
||||
}
|
||||
|
||||
export function catalogRelationFromMap(
|
||||
map: Map<string, DefaultRelation>,
|
||||
id: string | null | undefined,
|
||||
): DefaultRelation | null {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
return map.get(id) ?? null;
|
||||
}
|
||||
|
||||
export function codeRelationFromMap(
|
||||
map: Map<string, CodeRelation>,
|
||||
id: string | null | undefined,
|
||||
): CodeRelation | null {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
return map.get(id) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { inArray } from 'drizzle-orm';
|
||||
import type { UserRelation } from '../common/http/response';
|
||||
import type { DrizzleDB } from './database.module';
|
||||
import { users } from './schema';
|
||||
|
||||
export async function loadUserRelationMap(
|
||||
db: DrizzleDB,
|
||||
ids: readonly string[],
|
||||
): Promise<Map<string, UserRelation>> {
|
||||
const unique = [...new Set(ids.filter((id) => id.length > 0))];
|
||||
if (unique.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
const rows = await db
|
||||
.select({ id: users.id, username: users.username })
|
||||
.from(users)
|
||||
.where(inArray(users.id, unique));
|
||||
return new Map(
|
||||
rows.map((row) => [row.id, { id: row.id, username: row.username }]),
|
||||
);
|
||||
}
|
||||
|
||||
export function userRelationFromMap(
|
||||
map: Map<string, UserRelation>,
|
||||
id: string,
|
||||
): UserRelation {
|
||||
return map.get(id) ?? { id, username: '' };
|
||||
}
|
||||
|
||||
export async function attachAuditUsers<
|
||||
T extends { createdBy: string; updatedBy: string },
|
||||
>(
|
||||
db: DrizzleDB,
|
||||
items: T[],
|
||||
): Promise<
|
||||
Array<T & { createdByUser: UserRelation; updatedByUser: UserRelation }>
|
||||
> {
|
||||
const map = await loadUserRelationMap(
|
||||
db,
|
||||
items.flatMap((item) => [item.createdBy, item.updatedBy]),
|
||||
);
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
createdByUser: userRelationFromMap(map, item.createdBy),
|
||||
updatedByUser: userRelationFromMap(map, item.updatedBy),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
bigint,
|
||||
doublePrecision,
|
||||
index,
|
||||
numeric,
|
||||
pgTable,
|
||||
text,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
varchar,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { customers } from './customers-table';
|
||||
import { products } from './products-table';
|
||||
import { primaryEntityColumns } from './primary-entity-columns';
|
||||
import { salesOrders } from './sales-orders-table';
|
||||
import { users } from './schema';
|
||||
|
||||
export const packingSlips = pgTable(
|
||||
'packing_slips',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
code: varchar('code', { length: 32 }).notNull(),
|
||||
salesOrderId: uuid('sales_order_id').references(() => salesOrders.id, {
|
||||
onDelete: 'restrict',
|
||||
}),
|
||||
salesOrderNumber: varchar('sales_order_number', { length: 32 }),
|
||||
date: bigint('date', { mode: 'number' }).notNull(),
|
||||
customerId: uuid('customer_id')
|
||||
.notNull()
|
||||
.references(() => customers.id, { onDelete: 'restrict' }),
|
||||
address: text('address').notNull(),
|
||||
latitude: doublePrecision('latitude'),
|
||||
longitude: doublePrecision('longitude'),
|
||||
notes: text('notes'),
|
||||
...primaryEntityColumns(users),
|
||||
},
|
||||
(t) => [uniqueIndex('packing_slips_code_unique').on(t.code)],
|
||||
);
|
||||
|
||||
export const packingSlipProducts = pgTable(
|
||||
'packing_slip_products',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
packingSlipId: uuid('packing_slip_id')
|
||||
.notNull()
|
||||
.references(() => packingSlips.id, { onDelete: 'cascade' }),
|
||||
productId: uuid('product_id')
|
||||
.notNull()
|
||||
.references(() => products.id, { onDelete: 'restrict' }),
|
||||
quantity: numeric('quantity', { precision: 18, scale: 4 }).notNull(),
|
||||
price: numeric('price', { precision: 18, scale: 4 }).notNull(),
|
||||
},
|
||||
(t) => [
|
||||
index('packing_slip_products_packing_slip_id_idx').on(t.packingSlipId),
|
||||
],
|
||||
);
|
||||
|
||||
export type PackingSlipRow = typeof packingSlips.$inferSelect;
|
||||
export type NewPackingSlipRow = typeof packingSlips.$inferInsert;
|
||||
export type PackingSlipProductRow = typeof packingSlipProducts.$inferSelect;
|
||||
@@ -0,0 +1,111 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {
|
||||
bigint,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { branches } from './branches-table';
|
||||
import { customers } from './customers-table';
|
||||
import { employees } from './employees-table';
|
||||
import { packingSlips } from './packing-slips-table';
|
||||
import { primaryEntityColumns } from './primary-entity-columns';
|
||||
import { salesInvoices } from './sales-invoices-table';
|
||||
import { users } from './schema';
|
||||
import type { StoredRouteGeometry } from './cycles-table';
|
||||
|
||||
export const plans = pgTable(
|
||||
'plans',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
employeeId: uuid('employee_id')
|
||||
.notNull()
|
||||
.references(() => employees.id, { onDelete: 'restrict' }),
|
||||
purpose: text('purpose').notNull(),
|
||||
date: bigint('date', { mode: 'number' }).notNull(),
|
||||
startBranchId: uuid('start_branch_id')
|
||||
.notNull()
|
||||
.references(() => branches.id, { onDelete: 'restrict' }),
|
||||
endBranchId: uuid('end_branch_id')
|
||||
.notNull()
|
||||
.references(() => branches.id, { onDelete: 'restrict' }),
|
||||
routeGeometry: jsonb('route_geometry')
|
||||
.$type<StoredRouteGeometry>()
|
||||
.notNull(),
|
||||
...primaryEntityColumns(users),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('plans_employee_purpose_date_live_unique')
|
||||
.on(t.employeeId, t.purpose, t.date)
|
||||
.where(sql`${t.status} <> 'archived'`),
|
||||
index('plans_employee_id_idx').on(t.employeeId),
|
||||
],
|
||||
);
|
||||
|
||||
export const planDestinations = pgTable(
|
||||
'plan_destinations',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
planId: uuid('plan_id')
|
||||
.notNull()
|
||||
.references(() => plans.id, { onDelete: 'cascade' }),
|
||||
customerId: uuid('customer_id')
|
||||
.notNull()
|
||||
.references(() => customers.id, { onDelete: 'restrict' }),
|
||||
sortOrder: integer('sort_order').notNull(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('plan_destinations_plan_customer_unique').on(
|
||||
t.planId,
|
||||
t.customerId,
|
||||
),
|
||||
index('plan_destinations_plan_id_idx').on(t.planId),
|
||||
],
|
||||
);
|
||||
|
||||
export const planInvoices = pgTable(
|
||||
'plan_invoices',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
planId: uuid('plan_id')
|
||||
.notNull()
|
||||
.references(() => plans.id, { onDelete: 'cascade' }),
|
||||
invoiceId: uuid('invoice_id')
|
||||
.notNull()
|
||||
.references(() => salesInvoices.id, { onDelete: 'restrict' }),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('plan_invoices_plan_invoice_unique').on(t.planId, t.invoiceId),
|
||||
index('plan_invoices_plan_id_idx').on(t.planId),
|
||||
],
|
||||
);
|
||||
|
||||
export const planPackingSlips = pgTable(
|
||||
'plan_packing_slips',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
planId: uuid('plan_id')
|
||||
.notNull()
|
||||
.references(() => plans.id, { onDelete: 'cascade' }),
|
||||
packingSlipId: uuid('packing_slip_id')
|
||||
.notNull()
|
||||
.references(() => packingSlips.id, { onDelete: 'restrict' }),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('plan_packing_slips_plan_slip_unique').on(
|
||||
t.planId,
|
||||
t.packingSlipId,
|
||||
),
|
||||
index('plan_packing_slips_plan_id_idx').on(t.planId),
|
||||
],
|
||||
);
|
||||
|
||||
export type PlanRow = typeof plans.$inferSelect;
|
||||
export type NewPlanRow = typeof plans.$inferInsert;
|
||||
export type PlanDestinationRow = typeof planDestinations.$inferSelect;
|
||||
export type PlanInvoiceRow = typeof planInvoices.$inferSelect;
|
||||
export type PlanPackingSlipRow = typeof planPackingSlips.$inferSelect;
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
numeric,
|
||||
pgTable,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
varchar,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { primaryEntityColumns } from './primary-entity-columns';
|
||||
import { users } from './schema';
|
||||
|
||||
/**
|
||||
* Products (primary aggregate).
|
||||
* Kept in a separate module so Drizzle's table type stays resolvable.
|
||||
*/
|
||||
export const products = pgTable(
|
||||
'products',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
code: varchar('code', { length: 32 }).notNull(),
|
||||
name: varchar('name', { length: 128 }).notNull(),
|
||||
unit: varchar('unit', { length: 16 }),
|
||||
price: numeric('price', { precision: 18, scale: 4 }),
|
||||
brand: varchar('brand', { length: 64 }),
|
||||
...primaryEntityColumns(users),
|
||||
},
|
||||
(t) => [uniqueIndex('products_code_unique').on(t.code)],
|
||||
);
|
||||
|
||||
export type ProductRow = typeof products.$inferSelect;
|
||||
export type NewProductRow = typeof products.$inferInsert;
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
bigint,
|
||||
index,
|
||||
numeric,
|
||||
pgTable,
|
||||
text,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
varchar,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { branches } from './branches-table';
|
||||
import { customers } from './customers-table';
|
||||
import { employees } from './employees-table';
|
||||
import { packingSlips } from './packing-slips-table';
|
||||
import { products } from './products-table';
|
||||
import { primaryEntityColumns } from './primary-entity-columns';
|
||||
import { salesOrders } from './sales-orders-table';
|
||||
import { divisions, users } from './schema';
|
||||
|
||||
export const salesInvoices = pgTable(
|
||||
'sales_invoices',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
code: varchar('code', { length: 32 }).notNull(),
|
||||
date: bigint('date', { mode: 'number' }).notNull(),
|
||||
salesPersonId: uuid('sales_person_id')
|
||||
.notNull()
|
||||
.references(() => employees.id, { onDelete: 'restrict' }),
|
||||
branchId: uuid('branch_id')
|
||||
.notNull()
|
||||
.references(() => branches.id, { onDelete: 'restrict' }),
|
||||
divisionId: uuid('division_id')
|
||||
.notNull()
|
||||
.references(() => divisions.id, { onDelete: 'restrict' }),
|
||||
customerId: uuid('customer_id')
|
||||
.notNull()
|
||||
.references(() => customers.id, { onDelete: 'restrict' }),
|
||||
salesOrderId: uuid('sales_order_id').references(() => salesOrders.id, {
|
||||
onDelete: 'restrict',
|
||||
}),
|
||||
salesOrderCode: varchar('sales_order_code', { length: 32 }),
|
||||
packingSlipId: uuid('packing_slip_id').references(() => packingSlips.id, {
|
||||
onDelete: 'restrict',
|
||||
}),
|
||||
packingSlipCode: varchar('packing_slip_code', { length: 32 }),
|
||||
balance: numeric('balance', { precision: 18, scale: 4 }).notNull(),
|
||||
notes: text('notes'),
|
||||
...primaryEntityColumns(users),
|
||||
},
|
||||
(t) => [uniqueIndex('sales_invoices_code_unique').on(t.code)],
|
||||
);
|
||||
|
||||
export const salesInvoiceProducts = pgTable(
|
||||
'sales_invoice_products',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
salesInvoiceId: uuid('sales_invoice_id')
|
||||
.notNull()
|
||||
.references(() => salesInvoices.id, { onDelete: 'cascade' }),
|
||||
productId: uuid('product_id')
|
||||
.notNull()
|
||||
.references(() => products.id, { onDelete: 'restrict' }),
|
||||
quantity: numeric('quantity', { precision: 18, scale: 4 }).notNull(),
|
||||
price: numeric('price', { precision: 18, scale: 4 }).notNull(),
|
||||
},
|
||||
(t) => [index('sales_invoice_products_invoice_id_idx').on(t.salesInvoiceId)],
|
||||
);
|
||||
|
||||
export type SalesInvoiceRow = typeof salesInvoices.$inferSelect;
|
||||
export type NewSalesInvoiceRow = typeof salesInvoices.$inferInsert;
|
||||
export type SalesInvoiceProductRow = typeof salesInvoiceProducts.$inferSelect;
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
bigint,
|
||||
doublePrecision,
|
||||
index,
|
||||
numeric,
|
||||
pgTable,
|
||||
text,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
varchar,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { branches } from './branches-table';
|
||||
import { customers } from './customers-table';
|
||||
import { employees } from './employees-table';
|
||||
import { products } from './products-table';
|
||||
import { salesRequests } from './sales-requests-table';
|
||||
import { primaryEntityColumns } from './primary-entity-columns';
|
||||
import { divisions, users } from './schema';
|
||||
|
||||
export const salesOrders = pgTable(
|
||||
'sales_orders',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
code: varchar('code', { length: 32 }).notNull(),
|
||||
salesRequestId: uuid('sales_request_id').references(
|
||||
() => salesRequests.id,
|
||||
{
|
||||
onDelete: 'restrict',
|
||||
},
|
||||
),
|
||||
date: bigint('date', { mode: 'number' }).notNull(),
|
||||
salesPersonId: uuid('sales_person_id')
|
||||
.notNull()
|
||||
.references(() => employees.id, { onDelete: 'restrict' }),
|
||||
branchId: uuid('branch_id')
|
||||
.notNull()
|
||||
.references(() => branches.id, { onDelete: 'restrict' }),
|
||||
divisionId: uuid('division_id')
|
||||
.notNull()
|
||||
.references(() => divisions.id, { onDelete: 'restrict' }),
|
||||
customerId: uuid('customer_id')
|
||||
.notNull()
|
||||
.references(() => customers.id, { onDelete: 'restrict' }),
|
||||
address: text('address').notNull(),
|
||||
latitude: doublePrecision('latitude'),
|
||||
longitude: doublePrecision('longitude'),
|
||||
notes: text('notes'),
|
||||
...primaryEntityColumns(users),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('sales_orders_code_unique').on(t.code),
|
||||
index('sales_orders_customer_id_idx').on(t.customerId),
|
||||
],
|
||||
);
|
||||
|
||||
export const salesOrderProducts = pgTable(
|
||||
'sales_order_products',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
salesOrderId: uuid('sales_order_id')
|
||||
.notNull()
|
||||
.references(() => salesOrders.id, { onDelete: 'cascade' }),
|
||||
productId: uuid('product_id')
|
||||
.notNull()
|
||||
.references(() => products.id, { onDelete: 'restrict' }),
|
||||
quantity: numeric('quantity', { precision: 18, scale: 4 }).notNull(),
|
||||
price: numeric('price', { precision: 18, scale: 4 }).notNull(),
|
||||
},
|
||||
(t) => [index('sales_order_products_request_id_idx').on(t.salesOrderId)],
|
||||
);
|
||||
|
||||
export const salesOrderImages = pgTable(
|
||||
'sales_order_images',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
salesOrderId: uuid('sales_order_id')
|
||||
.notNull()
|
||||
.references(() => salesOrders.id, { onDelete: 'cascade' }),
|
||||
url: varchar('url', { length: 2048 }).notNull(),
|
||||
description: varchar('description', { length: 255 }),
|
||||
},
|
||||
(t) => [index('sales_order_images_request_id_idx').on(t.salesOrderId)],
|
||||
);
|
||||
|
||||
export type SalesOrderRow = typeof salesOrders.$inferSelect;
|
||||
export type NewSalesOrderRow = typeof salesOrders.$inferInsert;
|
||||
export type SalesOrderProductRow = typeof salesOrderProducts.$inferSelect;
|
||||
export type SalesOrderImageRow = typeof salesOrderImages.$inferSelect;
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
bigint,
|
||||
index,
|
||||
numeric,
|
||||
pgTable,
|
||||
text,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
varchar,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { primaryEntityColumns } from './primary-entity-columns';
|
||||
import { salesInvoices } from './sales-invoices-table';
|
||||
import { users } from './schema';
|
||||
|
||||
export const salesPayments = pgTable(
|
||||
'sales_payments',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
code: varchar('code', { length: 32 }).notNull(),
|
||||
date: bigint('date', { mode: 'number' }).notNull(),
|
||||
notes: text('notes'),
|
||||
...primaryEntityColumns(users),
|
||||
},
|
||||
(t) => [uniqueIndex('sales_payments_code_unique').on(t.code)],
|
||||
);
|
||||
|
||||
export const salesPaymentImages = pgTable(
|
||||
'sales_payment_images',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
salesPaymentId: uuid('sales_payment_id')
|
||||
.notNull()
|
||||
.references(() => salesPayments.id, { onDelete: 'cascade' }),
|
||||
url: varchar('url', { length: 2048 }).notNull(),
|
||||
description: varchar('description', { length: 255 }),
|
||||
},
|
||||
(t) => [index('sales_payment_images_payment_id_idx').on(t.salesPaymentId)],
|
||||
);
|
||||
|
||||
export const salesPaymentInvoices = pgTable(
|
||||
'sales_payment_invoices',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
salesPaymentId: uuid('sales_payment_id')
|
||||
.notNull()
|
||||
.references(() => salesPayments.id, { onDelete: 'cascade' }),
|
||||
salesInvoiceId: uuid('sales_invoice_id')
|
||||
.notNull()
|
||||
.references(() => salesInvoices.id, { onDelete: 'restrict' }),
|
||||
amount: numeric('amount', { precision: 18, scale: 4 }).notNull(),
|
||||
},
|
||||
(t) => [index('sales_payment_invoices_payment_id_idx').on(t.salesPaymentId)],
|
||||
);
|
||||
|
||||
export type SalesPaymentRow = typeof salesPayments.$inferSelect;
|
||||
export type NewSalesPaymentRow = typeof salesPayments.$inferInsert;
|
||||
export type SalesPaymentImageRow = typeof salesPaymentImages.$inferSelect;
|
||||
export type SalesPaymentInvoiceRow = typeof salesPaymentInvoices.$inferSelect;
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
bigint,
|
||||
doublePrecision,
|
||||
index,
|
||||
numeric,
|
||||
pgTable,
|
||||
text,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
varchar,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { branches } from './branches-table';
|
||||
import { customers } from './customers-table';
|
||||
import { employees } from './employees-table';
|
||||
import { products } from './products-table';
|
||||
import { primaryEntityColumns } from './primary-entity-columns';
|
||||
import { divisions, users } from './schema';
|
||||
|
||||
export const salesRequests = pgTable(
|
||||
'sales_requests',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
code: varchar('code', { length: 32 }).notNull(),
|
||||
date: bigint('date', { mode: 'number' }).notNull(),
|
||||
salesPersonId: uuid('sales_person_id')
|
||||
.notNull()
|
||||
.references(() => employees.id, { onDelete: 'restrict' }),
|
||||
branchId: uuid('branch_id')
|
||||
.notNull()
|
||||
.references(() => branches.id, { onDelete: 'restrict' }),
|
||||
divisionId: uuid('division_id')
|
||||
.notNull()
|
||||
.references(() => divisions.id, { onDelete: 'restrict' }),
|
||||
customerId: uuid('customer_id')
|
||||
.notNull()
|
||||
.references(() => customers.id, { onDelete: 'restrict' }),
|
||||
address: text('address').notNull(),
|
||||
latitude: doublePrecision('latitude'),
|
||||
longitude: doublePrecision('longitude'),
|
||||
notes: text('notes'),
|
||||
...primaryEntityColumns(users),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('sales_requests_code_unique').on(t.code),
|
||||
index('sales_requests_customer_id_idx').on(t.customerId),
|
||||
],
|
||||
);
|
||||
|
||||
export const salesRequestProducts = pgTable(
|
||||
'sales_request_products',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
salesRequestId: uuid('sales_request_id')
|
||||
.notNull()
|
||||
.references(() => salesRequests.id, { onDelete: 'cascade' }),
|
||||
productId: uuid('product_id')
|
||||
.notNull()
|
||||
.references(() => products.id, { onDelete: 'restrict' }),
|
||||
quantity: numeric('quantity', { precision: 18, scale: 4 }).notNull(),
|
||||
price: numeric('price', { precision: 18, scale: 4 }).notNull(),
|
||||
},
|
||||
(t) => [index('sales_request_products_request_id_idx').on(t.salesRequestId)],
|
||||
);
|
||||
|
||||
export const salesRequestImages = pgTable(
|
||||
'sales_request_images',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
salesRequestId: uuid('sales_request_id')
|
||||
.notNull()
|
||||
.references(() => salesRequests.id, { onDelete: 'cascade' }),
|
||||
url: varchar('url', { length: 2048 }).notNull(),
|
||||
description: varchar('description', { length: 255 }),
|
||||
},
|
||||
(t) => [index('sales_request_images_request_id_idx').on(t.salesRequestId)],
|
||||
);
|
||||
|
||||
export type SalesRequestRow = typeof salesRequests.$inferSelect;
|
||||
export type NewSalesRequestRow = typeof salesRequests.$inferInsert;
|
||||
export type SalesRequestProductRow = typeof salesRequestProducts.$inferSelect;
|
||||
export type SalesRequestImageRow = typeof salesRequestImages.$inferSelect;
|
||||
@@ -8,13 +8,17 @@ import {
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
varchar,
|
||||
type AnyPgColumn,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { Status } from '../common/value-objects/status/status';
|
||||
import { primaryEntityColumns } from './primary-entity-columns';
|
||||
|
||||
/**
|
||||
* Application users. Timestamps are UTC unix milliseconds.
|
||||
* privilege_id is nullable until a role is assigned (deny-by-default).
|
||||
* FK to privileges.id is enforced in the migration (circular table dependency).
|
||||
* Status / created_by / updated_by are declared here (not via primaryEntityColumns)
|
||||
* because this table cannot pass itself the same way other tables pass `users`.
|
||||
*/
|
||||
export const users = pgTable(
|
||||
'users',
|
||||
@@ -24,8 +28,15 @@ export const users = pgTable(
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
privilegeId: uuid('privilege_id'),
|
||||
isSuperadmin: boolean('is_superadmin').notNull().default(false),
|
||||
status: text('status').notNull().default(Status.DEFAULT),
|
||||
createdAt: bigint('created_at', { mode: 'number' }).notNull(),
|
||||
updatedAt: bigint('updated_at', { mode: 'number' }).notNull(),
|
||||
createdBy: uuid('created_by')
|
||||
.notNull()
|
||||
.references((): AnyPgColumn => users.id),
|
||||
updatedBy: uuid('updated_by')
|
||||
.notNull()
|
||||
.references((): AnyPgColumn => users.id),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('users_username_unique').on(t.username),
|
||||
@@ -143,3 +154,96 @@ export type DivisionRow = typeof divisions.$inferSelect;
|
||||
export type NewDivisionRow = typeof divisions.$inferInsert;
|
||||
|
||||
export { branches, type BranchRow, type NewBranchRow } from './branches-table';
|
||||
export {
|
||||
customerContacts,
|
||||
customers,
|
||||
type CustomerContactRow,
|
||||
type CustomerRow,
|
||||
type NewCustomerContactRow,
|
||||
type NewCustomerRow,
|
||||
} from './customers-table';
|
||||
export {
|
||||
employees,
|
||||
type EmployeeRow,
|
||||
type NewEmployeeRow,
|
||||
} from './employees-table';
|
||||
|
||||
export {
|
||||
products,
|
||||
type ProductRow,
|
||||
type NewProductRow,
|
||||
} from './products-table';
|
||||
|
||||
export {
|
||||
documentSequences,
|
||||
type DocumentSequenceRow,
|
||||
type NewDocumentSequenceRow,
|
||||
} from './document-sequences-table';
|
||||
export {
|
||||
salesRequestImages,
|
||||
salesRequestProducts,
|
||||
salesRequests,
|
||||
type NewSalesRequestRow,
|
||||
type SalesRequestImageRow,
|
||||
type SalesRequestProductRow,
|
||||
type SalesRequestRow,
|
||||
} from './sales-requests-table';
|
||||
export {
|
||||
salesOrderImages,
|
||||
salesOrderProducts,
|
||||
salesOrders,
|
||||
type NewSalesOrderRow,
|
||||
type SalesOrderImageRow,
|
||||
type SalesOrderProductRow,
|
||||
type SalesOrderRow,
|
||||
} from './sales-orders-table';
|
||||
|
||||
export {
|
||||
packingSlipProducts,
|
||||
packingSlips,
|
||||
type NewPackingSlipRow,
|
||||
type PackingSlipProductRow,
|
||||
type PackingSlipRow,
|
||||
} from './packing-slips-table';
|
||||
export {
|
||||
salesInvoiceProducts,
|
||||
salesInvoices,
|
||||
type NewSalesInvoiceRow,
|
||||
type SalesInvoiceProductRow,
|
||||
type SalesInvoiceRow,
|
||||
} from './sales-invoices-table';
|
||||
export {
|
||||
salesPaymentImages,
|
||||
salesPaymentInvoices,
|
||||
salesPayments,
|
||||
type NewSalesPaymentRow,
|
||||
type SalesPaymentImageRow,
|
||||
type SalesPaymentInvoiceRow,
|
||||
type SalesPaymentRow,
|
||||
} from './sales-payments-table';
|
||||
|
||||
export {
|
||||
companySettings,
|
||||
type CompanySettingsRow,
|
||||
type NewCompanySettingsRow,
|
||||
} from './company-settings-table';
|
||||
export {
|
||||
cycleDestinations,
|
||||
cycleWeekdays,
|
||||
cycles,
|
||||
type CycleDestinationRow,
|
||||
type CycleRow,
|
||||
type CycleWeekdayRow,
|
||||
type NewCycleRow,
|
||||
} from './cycles-table';
|
||||
export {
|
||||
planDestinations,
|
||||
planInvoices,
|
||||
planPackingSlips,
|
||||
plans,
|
||||
type NewPlanRow,
|
||||
type PlanDestinationRow,
|
||||
type PlanInvoiceRow,
|
||||
type PlanPackingSlipRow,
|
||||
type PlanRow,
|
||||
} from './plans-table';
|
||||
|
||||
@@ -7,6 +7,7 @@ async function bootstrap() {
|
||||
const env = loadEnv();
|
||||
const app = await NestFactory.create(AppModule);
|
||||
configureApp(app);
|
||||
app.enableCors();
|
||||
await app.listen(env.PORT);
|
||||
}
|
||||
void bootstrap();
|
||||
|
||||
@@ -17,8 +17,9 @@ describe('AuthController', () => {
|
||||
beforeEach(async () => {
|
||||
authService = {
|
||||
register: jest.fn().mockResolvedValue({
|
||||
accessToken: 'a',
|
||||
refreshToken: 'b'.repeat(64),
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
status: 'draft',
|
||||
}),
|
||||
login: jest.fn().mockResolvedValue({
|
||||
accessToken: 'a',
|
||||
@@ -105,6 +106,7 @@ describe('AuthController', () => {
|
||||
id: 'priv-1',
|
||||
name: 'Admin',
|
||||
code: 'ADMIN',
|
||||
status: 'active',
|
||||
});
|
||||
privilegesService.getPermissionsMap.mockResolvedValue({
|
||||
PRIVILEGES: {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
MeResponseDto,
|
||||
RefreshTokenDto,
|
||||
RegisterDto,
|
||||
RegisterResponseDto,
|
||||
TokenPairDto,
|
||||
} from './dto/auth.dto';
|
||||
|
||||
@@ -40,11 +41,11 @@ export class AuthController {
|
||||
@Throttle({ default: { limit: 5, ttl: 60_000 } })
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: 'Register a new user' })
|
||||
@ApiCreatedResponse({ type: TokenPairDto })
|
||||
@ApiCreatedResponse({ type: RegisterResponseDto })
|
||||
@ApiBadRequestResponse({ description: 'Validation failed' })
|
||||
@ApiConflictResponse({ description: 'Username already registered' })
|
||||
@ApiTooManyRequestsResponse({ description: 'Rate limit exceeded' })
|
||||
register(@Body() dto: RegisterDto): Promise<TokenPairDto> {
|
||||
register(@Body() dto: RegisterDto): Promise<RegisterResponseDto> {
|
||||
return this.authService.register(dto.username, dto.password);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,10 @@ import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
},
|
||||
}),
|
||||
}),
|
||||
ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]),
|
||||
ThrottlerModule.forRoot({
|
||||
skipIf: () => process.env.NODE_ENV === 'test',
|
||||
throttlers: [{ ttl: 60_000, limit: 100 }],
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Test, TestingModule } from '@nestjs/testing';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { DateTime } from '../../common/value-objects/date-time/date-time';
|
||||
import { Status } from '../../common/value-objects/status/status';
|
||||
import type { User } from '../users/user';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { AuthService } from './auth.service';
|
||||
@@ -17,7 +18,10 @@ import { RevokedAccessTokensRepository } from './revoked-access-tokens.repositor
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
let usersService: jest.Mocked<
|
||||
Pick<UsersService, 'create' | 'findByUsername' | 'findById'>
|
||||
Pick<
|
||||
UsersService,
|
||||
'create' | 'findByUsername' | 'findById' | 'assertCanAuthenticate'
|
||||
>
|
||||
>;
|
||||
let jwtService: jest.Mocked<Pick<JwtService, 'signAsync'>>;
|
||||
let config: { getOrThrow: jest.Mock };
|
||||
@@ -46,14 +50,22 @@ describe('AuthService', () => {
|
||||
passwordHash: await bcrypt.hash('password123', 4),
|
||||
privilegeId: null,
|
||||
isSuperadmin: false,
|
||||
status: Status.create('active'),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: 'user-1',
|
||||
updatedBy: 'user-1',
|
||||
privilege: null,
|
||||
employee: null,
|
||||
createdByUser: { id: 'user-1', username: 'alice' },
|
||||
updatedByUser: { id: 'user-1', username: 'alice' },
|
||||
};
|
||||
|
||||
usersService = {
|
||||
create: jest.fn(),
|
||||
findByUsername: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
assertCanAuthenticate: jest.fn(),
|
||||
};
|
||||
jwtService = {
|
||||
signAsync: jest.fn().mockResolvedValue('access.jwt.token'),
|
||||
@@ -108,17 +120,22 @@ describe('AuthService', () => {
|
||||
service = moduleRef.get(AuthService);
|
||||
});
|
||||
|
||||
it('register creates user and returns token pair', async () => {
|
||||
it('register creates a draft user and does not issue tokens', async () => {
|
||||
usersService.findByUsername.mockResolvedValue(null);
|
||||
usersService.create.mockResolvedValue(user);
|
||||
usersService.create.mockResolvedValue({
|
||||
...user,
|
||||
status: Status.create('draft'),
|
||||
});
|
||||
|
||||
const pair = await service.register('Alice', 'password123');
|
||||
const result = await service.register('Alice', 'password123');
|
||||
|
||||
expect(usersService.create).toHaveBeenCalled();
|
||||
expect(pair.accessToken).toBe('access.jwt.token');
|
||||
expect(pair.refreshToken).toHaveLength(64);
|
||||
expect(Object.keys(pair).sort()).toEqual(['accessToken', 'refreshToken']);
|
||||
expect(refreshTokensRepository.create).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
status: 'draft',
|
||||
});
|
||||
expect(refreshTokensRepository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('register throws ConflictException when username exists', async () => {
|
||||
|
||||
@@ -33,7 +33,10 @@ export class AuthService {
|
||||
private readonly revokedAccessTokensRepository: RevokedAccessTokensRepository,
|
||||
) {}
|
||||
|
||||
async register(username: string, password: string): Promise<TokenPair> {
|
||||
async register(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<{ id: string; username: string; status: string }> {
|
||||
const existing = await this.usersService.findByUsername(username);
|
||||
if (existing) {
|
||||
throw new ConflictException('Username already registered');
|
||||
@@ -41,8 +44,11 @@ export class AuthService {
|
||||
const saltRounds = this.config.getOrThrow<number>('BCRYPT_SALT_ROUNDS');
|
||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||
const user = await this.usersService.create(username, passwordHash);
|
||||
const { tokens } = await this.issueTokenPair(user);
|
||||
return tokens;
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
status: user.status.value,
|
||||
};
|
||||
}
|
||||
|
||||
async login(username: string, password: string): Promise<TokenPair> {
|
||||
@@ -52,6 +58,7 @@ export class AuthService {
|
||||
if (!user || !match) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
this.usersService.assertCanAuthenticate(user);
|
||||
const { tokens } = await this.issueTokenPair(user);
|
||||
return tokens;
|
||||
}
|
||||
@@ -78,6 +85,7 @@ export class AuthService {
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
this.usersService.assertCanAuthenticate(user);
|
||||
|
||||
await this.denylistAccessJti(claimed.accessJti);
|
||||
const issued = await this.issueTokenPair(user);
|
||||
|
||||
@@ -54,6 +54,17 @@ export class RefreshTokenDto {
|
||||
refreshToken!: string;
|
||||
}
|
||||
|
||||
export class RegisterResponseDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: 'alice' })
|
||||
username!: string;
|
||||
|
||||
@ApiProperty({ example: 'draft' })
|
||||
status!: string;
|
||||
}
|
||||
|
||||
export class TokenPairDto implements TokenPair {
|
||||
@ApiProperty({
|
||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.example',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import type { User } from '../../users/user';
|
||||
import { UsersService } from '../../users/users.service';
|
||||
import { RevokedAccessTokensRepository } from '../revoked-access-tokens.repository';
|
||||
@@ -9,7 +10,9 @@ import { JwtStrategy } from './jwt.strategy';
|
||||
|
||||
describe('JwtStrategy', () => {
|
||||
let strategy: JwtStrategy;
|
||||
let usersService: jest.Mocked<Pick<UsersService, 'findById'>>;
|
||||
let usersService: jest.Mocked<
|
||||
Pick<UsersService, 'findById' | 'assertCanAuthenticate'>
|
||||
>;
|
||||
let revoked: jest.Mocked<Pick<RevokedAccessTokensRepository, 'exists'>>;
|
||||
|
||||
const now = DateTime.fromUnixMs(1_700_000_000_000);
|
||||
@@ -19,12 +22,22 @@ describe('JwtStrategy', () => {
|
||||
passwordHash: 'hash',
|
||||
privilegeId: null,
|
||||
isSuperadmin: false,
|
||||
status: Status.create('active'),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: 'user-1',
|
||||
updatedBy: 'user-1',
|
||||
privilege: null,
|
||||
employee: null,
|
||||
createdByUser: { id: 'user-1', username: 'alice' },
|
||||
updatedByUser: { id: 'user-1', username: 'alice' },
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
usersService = { findById: jest.fn() };
|
||||
usersService = {
|
||||
findById: jest.fn(),
|
||||
assertCanAuthenticate: jest.fn(),
|
||||
};
|
||||
revoked = { exists: jest.fn() };
|
||||
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
|
||||
@@ -40,6 +40,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
this.usersService.assertCanAuthenticate(user);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
|
||||
@@ -21,6 +21,19 @@ export type Branch = {
|
||||
readonly updatedAt: DateTime;
|
||||
readonly createdBy: string;
|
||||
readonly updatedBy: string;
|
||||
readonly division: {
|
||||
readonly id: string;
|
||||
readonly code: string;
|
||||
readonly name: string;
|
||||
} | null;
|
||||
readonly createdByUser: {
|
||||
readonly id: string;
|
||||
readonly username: string;
|
||||
};
|
||||
readonly updatedByUser: {
|
||||
readonly id: string;
|
||||
readonly username: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type CreateBranchInput = {
|
||||
@@ -69,6 +82,8 @@ export type ListBranchesFilters = {
|
||||
readonly workingHoursStart?: string;
|
||||
readonly workingHoursEnd?: string;
|
||||
readonly search?: string;
|
||||
readonly orderBy?: string;
|
||||
readonly orderType?: string;
|
||||
readonly limit: number;
|
||||
readonly offset: number;
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ describe('BranchesRepository', () => {
|
||||
const del = jest.fn();
|
||||
const transaction = jest.fn();
|
||||
const $dynamic = jest.fn();
|
||||
const leftJoin = jest.fn();
|
||||
|
||||
const db = {
|
||||
select,
|
||||
@@ -56,6 +57,13 @@ describe('BranchesRepository', () => {
|
||||
updatedBy: 'user-1',
|
||||
};
|
||||
|
||||
const joinedRow = {
|
||||
branch: row,
|
||||
division: { id: 'div-1', code: 'JKT', name: 'Jakarta' },
|
||||
createdByUser: { id: 'user-1', username: 'admin' },
|
||||
updatedByUser: { id: 'user-1', username: 'admin' },
|
||||
};
|
||||
|
||||
const createInput = {
|
||||
code: 'JKT_01',
|
||||
name: 'Jakarta Pusat',
|
||||
@@ -68,16 +76,34 @@ describe('BranchesRepository', () => {
|
||||
userId: 'user-1',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
where.mockImplementation(() => ({ limit, orderBy }));
|
||||
orderBy.mockImplementation(() => ({ limit }));
|
||||
limit.mockImplementation(() => ({ offset }));
|
||||
offset.mockResolvedValue([row]);
|
||||
from.mockImplementation(() => ({
|
||||
const joinChain = () => {
|
||||
const chain: {
|
||||
leftJoin: jest.Mock;
|
||||
where: typeof where;
|
||||
$dynamic: typeof $dynamic;
|
||||
} = {
|
||||
leftJoin: jest.fn(),
|
||||
where,
|
||||
$dynamic,
|
||||
};
|
||||
chain.leftJoin.mockReturnValue(chain);
|
||||
return chain;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
where.mockImplementation(() => ({ limit, orderBy, returning }));
|
||||
orderBy.mockImplementation(() => ({ limit }));
|
||||
limit.mockImplementation(() => ({
|
||||
offset,
|
||||
then: (
|
||||
resolve: (value: (typeof joinedRow)[]) => unknown,
|
||||
reject?: (reason: unknown) => unknown,
|
||||
) => Promise.resolve([joinedRow]).then(resolve, reject),
|
||||
}));
|
||||
offset.mockResolvedValue([joinedRow]);
|
||||
from.mockImplementation(() => joinChain());
|
||||
leftJoin.mockImplementation(() => joinChain());
|
||||
$dynamic.mockReturnValue({ where });
|
||||
select.mockImplementation(() => ({ from }));
|
||||
values.mockReturnValue({ returning });
|
||||
@@ -86,7 +112,6 @@ describe('BranchesRepository', () => {
|
||||
update.mockReturnValue({ set });
|
||||
del.mockReturnValue({ where });
|
||||
returning.mockResolvedValue([row]);
|
||||
where.mockImplementation(() => ({ limit, orderBy, returning }));
|
||||
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [BranchesRepository, { provide: DRIZZLE, useValue: db }],
|
||||
@@ -95,13 +120,15 @@ describe('BranchesRepository', () => {
|
||||
});
|
||||
|
||||
it('findById maps a row to domain Branch', async () => {
|
||||
limit.mockResolvedValueOnce([row]);
|
||||
const branch = await repository.findById('br-1');
|
||||
expect(branch).toMatchObject({
|
||||
id: 'br-1',
|
||||
code: 'JKT_01',
|
||||
name: 'Jakarta Pusat',
|
||||
createdBy: 'user-1',
|
||||
division: { id: 'div-1', code: 'JKT', name: 'Jakarta' },
|
||||
createdByUser: { id: 'user-1', username: 'admin' },
|
||||
updatedByUser: { id: 'user-1', username: 'admin' },
|
||||
});
|
||||
expect(branch?.phone.value).toBe('+6281234567890');
|
||||
expect(branch?.status.value).toBe('draft');
|
||||
@@ -109,12 +136,11 @@ describe('BranchesRepository', () => {
|
||||
});
|
||||
|
||||
it('findById returns null when missing', async () => {
|
||||
limit.mockResolvedValueOnce([]);
|
||||
limit.mockImplementationOnce(() => Promise.resolve([]));
|
||||
await expect(repository.findById('missing')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('findByCode maps a row', async () => {
|
||||
limit.mockResolvedValueOnce([row]);
|
||||
const branch = await repository.findByCode('JKT_01');
|
||||
expect(branch?.code).toBe('JKT_01');
|
||||
});
|
||||
@@ -127,17 +153,10 @@ describe('BranchesRepository', () => {
|
||||
}),
|
||||
}))
|
||||
.mockImplementationOnce(() => ({
|
||||
from: () => ({
|
||||
$dynamic: () => ({
|
||||
where: () => ({
|
||||
orderBy: () => ({
|
||||
limit: () => ({
|
||||
offset: () => Promise.resolve([row]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
from: () => {
|
||||
const chain = joinChain();
|
||||
return chain;
|
||||
},
|
||||
}));
|
||||
|
||||
const result = await repository.list({
|
||||
@@ -158,12 +177,18 @@ describe('BranchesRepository', () => {
|
||||
});
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.data[0].code).toBe('JKT_01');
|
||||
expect(result.data[0].division).toEqual({
|
||||
id: 'div-1',
|
||||
code: 'JKT',
|
||||
name: 'Jakarta',
|
||||
});
|
||||
});
|
||||
|
||||
it('create inserts and maps unique violations', async () => {
|
||||
returning.mockResolvedValueOnce([row]);
|
||||
const created = await repository.create(createInput);
|
||||
expect(created.code).toBe('JKT_01');
|
||||
expect(created.createdByUser).toEqual({ id: 'user-1', username: 'admin' });
|
||||
|
||||
returning.mockRejectedValueOnce({ code: '23505' });
|
||||
await expect(repository.create(createInput)).rejects.toBeInstanceOf(
|
||||
@@ -209,17 +234,17 @@ describe('BranchesRepository', () => {
|
||||
'user-1',
|
||||
);
|
||||
expect(updated.id).toBe('br-1');
|
||||
expect(updated.updatedByUser).toEqual({ id: 'user-1', username: 'admin' });
|
||||
});
|
||||
|
||||
it('update throws when missing', async () => {
|
||||
limit.mockResolvedValueOnce([]);
|
||||
limit.mockImplementationOnce(() => Promise.resolve([]));
|
||||
await expect(
|
||||
repository.update('missing', { userId: 'user-1' }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('update maps a row when present', async () => {
|
||||
limit.mockResolvedValueOnce([row]);
|
||||
returning.mockResolvedValueOnce([row]);
|
||||
const updated = await repository.update('br-1', {
|
||||
name: 'Jakarta Selatan',
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
|
||||
import { and, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
|
||||
import { toOrderClauses } from '../../../common/http/response';
|
||||
import { alias } from 'drizzle-orm/pg-core';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
type BranchRow,
|
||||
type NewBranchRow,
|
||||
} from '../../../database/branches-table';
|
||||
import { divisions, users } from '../../../database/schema';
|
||||
import type {
|
||||
Branch,
|
||||
CreateBranchInput,
|
||||
@@ -22,6 +25,28 @@ import type {
|
||||
UpdateBranchInput,
|
||||
} from './branch';
|
||||
|
||||
const BRANCH_ORDER_COLUMNS = {
|
||||
id: branches.id,
|
||||
code: branches.code,
|
||||
name: branches.name,
|
||||
phone: branches.phone,
|
||||
address: branches.address,
|
||||
nfcId: branches.nfcId,
|
||||
status: branches.status,
|
||||
createdAt: branches.createdAt,
|
||||
updatedAt: branches.updatedAt,
|
||||
};
|
||||
|
||||
const createdByUsers = alias(users, 'created_by_users');
|
||||
const updatedByUsers = alias(users, 'updated_by_users');
|
||||
|
||||
type BranchJoinedRow = {
|
||||
branch: BranchRow;
|
||||
division: typeof divisions.$inferSelect | null;
|
||||
createdByUser: typeof users.$inferSelect | null;
|
||||
updatedByUser: typeof users.$inferSelect | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class BranchesRepository {
|
||||
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||
@@ -36,11 +61,15 @@ export class BranchesRepository {
|
||||
.where(where);
|
||||
const totalRow = totalRows[0];
|
||||
|
||||
let qb = this.db.select().from(branches).$dynamic();
|
||||
let qb = this.selectWithRelations().$dynamic();
|
||||
qb = this.extendListQuery(qb, filters);
|
||||
const rows = await qb
|
||||
.where(where)
|
||||
.orderBy(asc(branches.code))
|
||||
.orderBy(
|
||||
...toOrderClauses(BRANCH_ORDER_COLUMNS, filters, [
|
||||
{ column: 'code', type: 'ASC' },
|
||||
]),
|
||||
)
|
||||
.limit(filters.limit)
|
||||
.offset(filters.offset);
|
||||
|
||||
@@ -59,9 +88,7 @@ export class BranchesRepository {
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Branch | null> {
|
||||
const rows: BranchRow[] = await this.db
|
||||
.select()
|
||||
.from(branches)
|
||||
const rows = await this.selectWithRelations()
|
||||
.where(eq(branches.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
@@ -69,9 +96,7 @@ export class BranchesRepository {
|
||||
}
|
||||
|
||||
async findByCode(code: string): Promise<Branch | null> {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(branches)
|
||||
const rows = await this.selectWithRelations()
|
||||
.where(eq(branches.code, code))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
@@ -87,7 +112,7 @@ export class BranchesRepository {
|
||||
.values(this.toInsertValues(input, status, now, input.userId))
|
||||
.returning();
|
||||
const row = inserted[0];
|
||||
return this.toDomain(row);
|
||||
return this.requireById(row.id);
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
}
|
||||
@@ -151,7 +176,7 @@ export class BranchesRepository {
|
||||
if (!row) {
|
||||
throw new NotFoundException('Branch not found');
|
||||
}
|
||||
return this.toDomain(row);
|
||||
return this.requireById(row.id);
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
}
|
||||
@@ -176,7 +201,7 @@ export class BranchesRepository {
|
||||
if (!row) {
|
||||
throw new NotFoundException('Branch not found');
|
||||
}
|
||||
return this.toDomain(row);
|
||||
return this.requireById(row.id);
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
@@ -221,6 +246,28 @@ export class BranchesRepository {
|
||||
return deleted.length;
|
||||
}
|
||||
|
||||
private selectWithRelations() {
|
||||
return this.db
|
||||
.select({
|
||||
branch: branches,
|
||||
division: divisions,
|
||||
createdByUser: createdByUsers,
|
||||
updatedByUser: updatedByUsers,
|
||||
})
|
||||
.from(branches)
|
||||
.leftJoin(divisions, eq(branches.divisionId, divisions.id))
|
||||
.leftJoin(createdByUsers, eq(branches.createdBy, createdByUsers.id))
|
||||
.leftJoin(updatedByUsers, eq(branches.updatedBy, updatedByUsers.id));
|
||||
}
|
||||
|
||||
private async requireById(id: string): Promise<Branch> {
|
||||
const loaded = await this.findById(id);
|
||||
if (!loaded) {
|
||||
throw new NotFoundException('Branch not found');
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
|
||||
private buildListWhere(filters: ListBranchesFilters): SQL | undefined {
|
||||
const parts: SQL[] = [];
|
||||
if (filters.code) {
|
||||
@@ -299,29 +346,52 @@ export class BranchesRepository {
|
||||
};
|
||||
}
|
||||
|
||||
private toDomain(row: BranchRow): Branch {
|
||||
private toDomain(row: BranchJoinedRow): Branch {
|
||||
const branch = row.branch;
|
||||
return {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
phone: PhoneNumber.create(row.phone),
|
||||
address: row.address,
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
workingDaysStart: row.workingDaysStart,
|
||||
workingDaysEnd: row.workingDaysEnd,
|
||||
workingHoursStart: row.workingHoursStart,
|
||||
workingHoursEnd: row.workingHoursEnd,
|
||||
nfcId: row.nfcId,
|
||||
divisionId: row.divisionId,
|
||||
status: Status.create(row.status),
|
||||
createdAt: DateTime.fromUnixMs(row.createdAt),
|
||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy,
|
||||
id: branch.id,
|
||||
code: branch.code,
|
||||
name: branch.name,
|
||||
phone: PhoneNumber.create(branch.phone),
|
||||
address: branch.address,
|
||||
latitude: branch.latitude,
|
||||
longitude: branch.longitude,
|
||||
workingDaysStart: branch.workingDaysStart,
|
||||
workingDaysEnd: branch.workingDaysEnd,
|
||||
workingHoursStart: branch.workingHoursStart,
|
||||
workingHoursEnd: branch.workingHoursEnd,
|
||||
nfcId: branch.nfcId,
|
||||
divisionId: branch.divisionId,
|
||||
status: Status.create(branch.status),
|
||||
createdAt: DateTime.fromUnixMs(branch.createdAt),
|
||||
updatedAt: DateTime.fromUnixMs(branch.updatedAt),
|
||||
createdBy: branch.createdBy,
|
||||
updatedBy: branch.updatedBy,
|
||||
division: this.toDefaultRelation(row.division),
|
||||
createdByUser: this.toUserRelation(row.createdByUser, branch.createdBy),
|
||||
updatedByUser: this.toUserRelation(row.updatedByUser, branch.updatedBy),
|
||||
};
|
||||
}
|
||||
|
||||
private toDefaultRelation(
|
||||
row: { id: string; code: string; name: string } | null,
|
||||
): Branch['division'] {
|
||||
if (!row?.id) {
|
||||
return null;
|
||||
}
|
||||
return { id: row.id, code: row.code, name: row.name };
|
||||
}
|
||||
|
||||
private toUserRelation(
|
||||
row: { id: string; username: string } | null,
|
||||
fallbackId: string,
|
||||
): Branch['createdByUser'] {
|
||||
if (row?.id) {
|
||||
return { id: row.id, username: row.username };
|
||||
}
|
||||
return { id: fallbackId, username: '' };
|
||||
}
|
||||
|
||||
private rethrowConstraintViolation(error: unknown): never {
|
||||
const err = error as { code?: string; constraint?: string };
|
||||
if (err.code === '23505') {
|
||||
|
||||
@@ -44,6 +44,9 @@ describe('BranchesService', () => {
|
||||
updatedAt: now,
|
||||
createdBy: 'user-1',
|
||||
updatedBy: 'user-1',
|
||||
division: { id: 'div-1', code: 'JKT', name: 'Jakarta' },
|
||||
createdByUser: { id: 'user-1', username: 'admin' },
|
||||
updatedByUser: { id: 'user-1', username: 'admin' },
|
||||
};
|
||||
|
||||
const createInput = {
|
||||
@@ -92,8 +95,14 @@ describe('BranchesService', () => {
|
||||
phone: '+6281234567890',
|
||||
status: 'draft',
|
||||
createdAt: now.value,
|
||||
division: { id: 'div-1', code: 'JKT', name: 'Jakarta' },
|
||||
createdBy: { id: 'user-1', username: 'admin' },
|
||||
updatedBy: { id: 'user-1', username: 'admin' },
|
||||
});
|
||||
expect(result.data[0]).not.toHaveProperty('divisionId');
|
||||
expect(service.visibleFields).toContain('phone');
|
||||
expect(service.visibleFields).toContain('division');
|
||||
expect(service.visibleFields).not.toContain('divisionId');
|
||||
});
|
||||
|
||||
it('findById throws when missing', async () => {
|
||||
@@ -108,6 +117,12 @@ describe('BranchesService', () => {
|
||||
const result = await service.findById('br-1');
|
||||
expect(result.id).toBe('br-1');
|
||||
expect(result.phone).toBe('+6281234567890');
|
||||
expect(result.division).toEqual({
|
||||
id: 'div-1',
|
||||
code: 'JKT',
|
||||
name: 'Jakarta',
|
||||
});
|
||||
expect(result.createdBy).toEqual({ id: 'user-1', username: 'admin' });
|
||||
});
|
||||
|
||||
it('create defaults status to draft and stores E.164 phone', async () => {
|
||||
|
||||
@@ -4,7 +4,12 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { PaginationResponse } from '../../../common/http/response';
|
||||
import { toListPage } from '../../../common/http/response';
|
||||
import {
|
||||
pickRelation,
|
||||
pickUserRelation,
|
||||
DEFAULT_RELATION_FIELDS,
|
||||
toListPage,
|
||||
} from '../../../common/http/response';
|
||||
import { InvalidPhoneNumberError } from '../../../common/value-objects/phone-number/invalid-phone-number.error';
|
||||
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
@@ -36,6 +41,8 @@ export type ListBranchesQuery = {
|
||||
readonly workingHoursStart?: string;
|
||||
readonly workingHoursEnd?: string;
|
||||
readonly search?: string;
|
||||
readonly orderBy?: string;
|
||||
readonly orderType?: string;
|
||||
readonly page?: number;
|
||||
readonly limit?: number;
|
||||
readonly offset?: number;
|
||||
@@ -54,7 +61,7 @@ const VISIBLE_FIELDS = [
|
||||
'workingHoursStart',
|
||||
'workingHoursEnd',
|
||||
'nfcId',
|
||||
'divisionId',
|
||||
'division',
|
||||
'status',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
@@ -94,6 +101,8 @@ export class BranchesService {
|
||||
workingHoursStart: query.workingHoursStart,
|
||||
workingHoursEnd: query.workingHoursEnd,
|
||||
search: query.search,
|
||||
orderBy: query.orderBy,
|
||||
orderType: query.orderType,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
});
|
||||
@@ -113,6 +122,16 @@ export class BranchesService {
|
||||
return this.toListItem(branch);
|
||||
}
|
||||
|
||||
async findByCode(
|
||||
code: string,
|
||||
): Promise<ReturnType<BranchesService['toListItem']>> {
|
||||
const branch = await this.branchesRepository.findByCode(code);
|
||||
if (!branch) {
|
||||
throw new NotFoundException('Branch not found');
|
||||
}
|
||||
return this.toListItem(branch);
|
||||
}
|
||||
|
||||
async create(input: {
|
||||
code: string;
|
||||
name: string;
|
||||
@@ -331,12 +350,12 @@ export class BranchesService {
|
||||
workingHoursStart: branch.workingHoursStart,
|
||||
workingHoursEnd: branch.workingHoursEnd,
|
||||
nfcId: branch.nfcId,
|
||||
divisionId: branch.divisionId,
|
||||
division: pickRelation(branch.division, DEFAULT_RELATION_FIELDS),
|
||||
status: branch.status.value,
|
||||
createdAt: branch.createdAt.value,
|
||||
updatedAt: branch.updatedAt.value,
|
||||
createdBy: branch.createdBy,
|
||||
updatedBy: branch.updatedBy,
|
||||
createdBy: pickUserRelation(branch.createdByUser),
|
||||
updatedBy: pickUserRelation(branch.updatedByUser),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,11 @@ import {
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../../common/http/response';
|
||||
import {
|
||||
DefaultRelationDto,
|
||||
PaginationQueryDto,
|
||||
UserRelationDto,
|
||||
} from '../../../../common/http/response';
|
||||
import { CORE_STATUSES } from '../../../../common/value-objects/status/status';
|
||||
import {
|
||||
BRANCH_ADDRESS_MAX_LENGTH,
|
||||
@@ -320,8 +324,8 @@ export class BranchDto {
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
nfcId!: string | null;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', nullable: true })
|
||||
divisionId!: string | null;
|
||||
@ApiPropertyOptional({ type: DefaultRelationDto, nullable: true })
|
||||
division!: DefaultRelationDto | null;
|
||||
|
||||
@ApiProperty({ enum: CORE_STATUSES })
|
||||
status!: string;
|
||||
@@ -332,9 +336,9 @@ export class BranchDto {
|
||||
@ApiProperty({ description: 'Unix ms' })
|
||||
updatedAt!: number;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
createdBy!: string;
|
||||
@ApiProperty({ type: UserRelationDto })
|
||||
createdBy!: UserRelationDto;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
updatedBy!: string;
|
||||
@ApiProperty({ type: UserRelationDto })
|
||||
updatedBy!: UserRelationDto;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BranchesModule } from './branches/branches.module';
|
||||
import { CustomersModule } from './customers/customers.module';
|
||||
import { DivisionsModule } from './divisions/divisions.module';
|
||||
import { EmployeesModule } from './employees/employees.module';
|
||||
import { ProductsModule } from './products/products.module';
|
||||
|
||||
@Module({
|
||||
imports: [DivisionsModule, BranchesModule],
|
||||
exports: [DivisionsModule, BranchesModule],
|
||||
imports: [
|
||||
DivisionsModule,
|
||||
BranchesModule,
|
||||
CustomersModule,
|
||||
EmployeesModule,
|
||||
ProductsModule,
|
||||
],
|
||||
exports: [
|
||||
DivisionsModule,
|
||||
BranchesModule,
|
||||
CustomersModule,
|
||||
EmployeesModule,
|
||||
ProductsModule,
|
||||
],
|
||||
})
|
||||
export class ConfigurationModule {}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
CONTACT_NAME_MAX_LENGTH,
|
||||
CUSTOMER_CODE_MAX_LENGTH,
|
||||
CUSTOMER_NAME_MAX_LENGTH,
|
||||
isAllowedCsvUpload,
|
||||
isValidContactJobTitle,
|
||||
isValidContactName,
|
||||
isValidContactNotes,
|
||||
isValidCustomerAddress,
|
||||
isValidCustomerCode,
|
||||
isValidCustomerName,
|
||||
isValidLatitude,
|
||||
isValidLongitude,
|
||||
isValidNfcId,
|
||||
parseCsvRecord,
|
||||
} from './customer-fields';
|
||||
|
||||
describe('customer fields', () => {
|
||||
describe('isValidCustomerName', () => {
|
||||
it.each(['Acme', 'South Jakarta', 'A', 'North West Region'])(
|
||||
'accepts %s',
|
||||
(name) => {
|
||||
expect(isValidCustomerName(name)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
'',
|
||||
'Acme1',
|
||||
'South-Jakarta',
|
||||
'CUST_01',
|
||||
' Acme',
|
||||
'Acme ',
|
||||
'South Jakarta',
|
||||
])('rejects %s', (name) => {
|
||||
expect(isValidCustomerName(name)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects names longer than 64 characters', () => {
|
||||
expect(
|
||||
isValidCustomerName('A'.repeat(CUSTOMER_NAME_MAX_LENGTH + 1)),
|
||||
).toBe(false);
|
||||
expect(isValidCustomerName('A'.repeat(CUSTOMER_NAME_MAX_LENGTH))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidCustomerCode', () => {
|
||||
it.each(['CUST', 'CUST_01', 'A', 'ops2', 'A_b_1'])('accepts %s', (code) => {
|
||||
expect(isValidCustomerCode(code)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['', 'CUST 01', 'CUST-01', 'CUST.01', ' CUST', 'CUST '])(
|
||||
'rejects %s',
|
||||
(code) => {
|
||||
expect(isValidCustomerCode(code)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects codes longer than 16 characters', () => {
|
||||
expect(
|
||||
isValidCustomerCode('A'.repeat(CUSTOMER_CODE_MAX_LENGTH + 1)),
|
||||
).toBe(false);
|
||||
expect(isValidCustomerCode('A'.repeat(CUSTOMER_CODE_MAX_LENGTH))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidCustomerAddress', () => {
|
||||
it('accepts a non-empty address', () => {
|
||||
expect(isValidCustomerAddress('Jl Sudirman No 1')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty or oversized addresses', () => {
|
||||
expect(isValidCustomerAddress('')).toBe(false);
|
||||
expect(isValidCustomerAddress('A'.repeat(256))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('coordinates', () => {
|
||||
it('accepts latitude and longitude in range', () => {
|
||||
expect(isValidLatitude(-90)).toBe(true);
|
||||
expect(isValidLatitude(90)).toBe(true);
|
||||
expect(isValidLongitude(-180)).toBe(true);
|
||||
expect(isValidLongitude(180)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects out of range coordinates', () => {
|
||||
expect(isValidLatitude(-90.1)).toBe(false);
|
||||
expect(isValidLatitude(90.1)).toBe(false);
|
||||
expect(isValidLongitude(-180.1)).toBe(false);
|
||||
expect(isValidLongitude(180.1)).toBe(false);
|
||||
expect(isValidLatitude(Number.NaN)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidNfcId', () => {
|
||||
it('accepts a non-empty NFC id', () => {
|
||||
expect(isValidNfcId('NFC-001')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty NFC id', () => {
|
||||
expect(isValidNfcId('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidContactName', () => {
|
||||
it.each(["O'Brien", 'Jean-Luc', 'A', 'Li Wei'])('accepts %s', (name) => {
|
||||
expect(isValidContactName(name)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty or oversized names', () => {
|
||||
expect(isValidContactName('')).toBe(false);
|
||||
expect(isValidContactName(' ')).toBe(false);
|
||||
expect(isValidContactName('A'.repeat(CONTACT_NAME_MAX_LENGTH + 1))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidContactJobTitle and notes', () => {
|
||||
it('accepts optional job title and notes within limits', () => {
|
||||
expect(isValidContactJobTitle('Purchasing Manager')).toBe(true);
|
||||
expect(isValidContactNotes('Call after 9am')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects oversized job title or notes', () => {
|
||||
expect(isValidContactJobTitle('A'.repeat(65))).toBe(false);
|
||||
expect(isValidContactNotes('A'.repeat(256))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCsvRecord', () => {
|
||||
it('keeps commas inside quoted fields', () => {
|
||||
expect(
|
||||
parseCsvRecord('CUST_01,Acme Corp,"Jl Sudirman No 1, Blok A"'),
|
||||
).toEqual(['CUST_01', 'Acme Corp', 'Jl Sudirman No 1, Blok A']);
|
||||
});
|
||||
|
||||
it('unescapes doubled quotes', () => {
|
||||
expect(parseCsvRecord('"Say ""hello""",x')).toEqual(['Say "hello"', 'x']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAllowedCsvUpload', () => {
|
||||
it('accepts csv mime or .csv names', () => {
|
||||
expect(
|
||||
isAllowedCsvUpload({
|
||||
mimetype: 'text/csv',
|
||||
originalname: 'x.txt',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isAllowedCsvUpload({
|
||||
mimetype: 'application/octet-stream',
|
||||
originalname: 'customers.csv',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-csv files', () => {
|
||||
expect(
|
||||
isAllowedCsvUpload({
|
||||
mimetype: 'application/pdf',
|
||||
originalname: 'x.pdf',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
export const CUSTOMER_NAME_MAX_LENGTH = 64;
|
||||
export const CUSTOMER_CODE_MAX_LENGTH = 16;
|
||||
export const CUSTOMER_ADDRESS_MAX_LENGTH = 255;
|
||||
export const CUSTOMER_NFC_ID_MAX_LENGTH = 64;
|
||||
export const CONTACT_NAME_MAX_LENGTH = 64;
|
||||
export const CONTACT_JOB_TITLE_MAX_LENGTH = 64;
|
||||
export const CONTACT_NOTES_MAX_LENGTH = 255;
|
||||
|
||||
/** Letters with single spaces between words. */
|
||||
export const CUSTOMER_NAME_PATTERN = /^[A-Za-z]+(?: [A-Za-z]+)*$/;
|
||||
|
||||
/** Alphanumeric and underscore; no spaces. */
|
||||
export const CUSTOMER_CODE_PATTERN = /^[A-Za-z0-9_]+$/;
|
||||
|
||||
export function isValidCustomerName(raw: string): boolean {
|
||||
return (
|
||||
typeof raw === 'string' &&
|
||||
raw.length > 0 &&
|
||||
raw.length <= CUSTOMER_NAME_MAX_LENGTH &&
|
||||
CUSTOMER_NAME_PATTERN.test(raw)
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidCustomerCode(raw: string): boolean {
|
||||
return (
|
||||
typeof raw === 'string' &&
|
||||
raw.length > 0 &&
|
||||
raw.length <= CUSTOMER_CODE_MAX_LENGTH &&
|
||||
CUSTOMER_CODE_PATTERN.test(raw)
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidCustomerAddress(raw: string): boolean {
|
||||
return (
|
||||
typeof raw === 'string' &&
|
||||
raw.length > 0 &&
|
||||
raw.length <= CUSTOMER_ADDRESS_MAX_LENGTH
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidLatitude(raw: number): boolean {
|
||||
return Number.isFinite(raw) && raw >= -90 && raw <= 90;
|
||||
}
|
||||
|
||||
export function isValidLongitude(raw: number): boolean {
|
||||
return Number.isFinite(raw) && raw >= -180 && raw <= 180;
|
||||
}
|
||||
|
||||
export function isValidNfcId(raw: string): boolean {
|
||||
return (
|
||||
typeof raw === 'string' &&
|
||||
raw.length > 0 &&
|
||||
raw.length <= CUSTOMER_NFC_ID_MAX_LENGTH
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidContactName(raw: string): boolean {
|
||||
return (
|
||||
typeof raw === 'string' &&
|
||||
raw.trim().length > 0 &&
|
||||
raw.trim().length <= CONTACT_NAME_MAX_LENGTH
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidContactJobTitle(raw: string): boolean {
|
||||
return typeof raw === 'string' && raw.length <= CONTACT_JOB_TITLE_MAX_LENGTH;
|
||||
}
|
||||
|
||||
export function isValidContactNotes(raw: string): boolean {
|
||||
return typeof raw === 'string' && raw.length <= CONTACT_NOTES_MAX_LENGTH;
|
||||
}
|
||||
|
||||
/** RFC 4180-style record split that preserves commas inside quotes. */
|
||||
export function parseCsvRecord(line: string): string[] {
|
||||
const cells: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (inQuotes) {
|
||||
if (ch === '"') {
|
||||
if (line[i + 1] === '"') {
|
||||
current += '"';
|
||||
i += 1;
|
||||
} else {
|
||||
inQuotes = false;
|
||||
}
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
} else if (ch === '"') {
|
||||
inQuotes = true;
|
||||
} else if (ch === ',') {
|
||||
cells.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
cells.push(current.trim());
|
||||
return cells;
|
||||
}
|
||||
|
||||
export function isAllowedCsvUpload(file: {
|
||||
mimetype: string;
|
||||
originalname: string;
|
||||
}): boolean {
|
||||
return (
|
||||
file.mimetype.includes('csv') ||
|
||||
file.originalname.toLowerCase().endsWith('.csv')
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { UserRelation } from '../../../common/http/response';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
|
||||
export type CustomerContact = {
|
||||
readonly id: string;
|
||||
readonly customerId: string;
|
||||
readonly name: string;
|
||||
readonly jobTitle: string | null;
|
||||
readonly phone: PhoneNumber | null;
|
||||
readonly mobilePhone: PhoneNumber | null;
|
||||
readonly notes: string | null;
|
||||
};
|
||||
|
||||
export type Customer = {
|
||||
readonly id: string;
|
||||
readonly code: string;
|
||||
readonly name: string;
|
||||
readonly phone: PhoneNumber;
|
||||
readonly address: string;
|
||||
readonly latitude: number | null;
|
||||
readonly longitude: number | null;
|
||||
readonly nfcId: string | null;
|
||||
readonly contacts: readonly CustomerContact[];
|
||||
readonly status: Status;
|
||||
readonly createdAt: DateTime;
|
||||
readonly updatedAt: DateTime;
|
||||
readonly createdBy: string;
|
||||
readonly updatedBy: string;
|
||||
readonly createdByUser: UserRelation;
|
||||
readonly updatedByUser: UserRelation;
|
||||
};
|
||||
|
||||
export type CustomerContactInput = {
|
||||
readonly name: string;
|
||||
readonly jobTitle?: string | null;
|
||||
readonly phone?: PhoneNumber | null;
|
||||
readonly mobilePhone?: PhoneNumber | null;
|
||||
readonly notes?: string | null;
|
||||
};
|
||||
|
||||
export type CreateCustomerInput = {
|
||||
readonly code: string;
|
||||
readonly name: string;
|
||||
readonly phone: PhoneNumber;
|
||||
readonly address: string;
|
||||
readonly latitude?: number | null;
|
||||
readonly longitude?: number | null;
|
||||
readonly nfcId?: string | null;
|
||||
readonly contacts?: readonly CustomerContactInput[];
|
||||
readonly status?: Status;
|
||||
readonly userId: string;
|
||||
};
|
||||
|
||||
export type UpdateCustomerInput = {
|
||||
readonly code?: string;
|
||||
readonly name?: string;
|
||||
readonly phone?: PhoneNumber;
|
||||
readonly address?: string;
|
||||
readonly latitude?: number | null;
|
||||
readonly longitude?: number | null;
|
||||
readonly nfcId?: string | null;
|
||||
readonly contacts?: readonly CustomerContactInput[];
|
||||
readonly userId: string;
|
||||
};
|
||||
|
||||
export type UpdateCustomerContactInput = {
|
||||
readonly name?: string;
|
||||
readonly jobTitle?: string | null;
|
||||
readonly phone?: PhoneNumber | null;
|
||||
readonly mobilePhone?: PhoneNumber | null;
|
||||
readonly notes?: string | null;
|
||||
readonly userId: string;
|
||||
};
|
||||
|
||||
export type ListCustomersFilters = {
|
||||
readonly code?: string;
|
||||
readonly name?: string;
|
||||
readonly phone?: string;
|
||||
readonly address?: string;
|
||||
readonly nfcId?: string;
|
||||
readonly status?: string;
|
||||
readonly search?: string;
|
||||
readonly orderBy?: string;
|
||||
readonly orderType?: string;
|
||||
readonly limit: number;
|
||||
readonly offset: number;
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { CustomersReadController } from './customers-read.controller';
|
||||
import { CustomersService } from './customers.service';
|
||||
|
||||
describe('CustomersReadController', () => {
|
||||
let controller: CustomersReadController;
|
||||
const service = {
|
||||
list: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
controllers: [CustomersReadController],
|
||||
providers: [{ provide: CustomersService, useValue: service }],
|
||||
}).compile();
|
||||
controller = moduleRef.get(CustomersReadController);
|
||||
});
|
||||
|
||||
it('list delegates to the service', async () => {
|
||||
service.list.mockResolvedValue({ data: [], total: 0 });
|
||||
await expect(controller.list({ page: 1 })).resolves.toEqual({
|
||||
data: [],
|
||||
total: 0,
|
||||
});
|
||||
expect(service.list).toHaveBeenCalledWith({ page: 1 });
|
||||
});
|
||||
|
||||
it('findOne delegates to the service', async () => {
|
||||
service.findById.mockResolvedValue({ id: 'cu-1' });
|
||||
await expect(controller.findOne('cu-1')).resolves.toEqual({ id: 'cu-1' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiForbiddenResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
||||
import {
|
||||
Pagination,
|
||||
type PaginationResponse,
|
||||
PaginationMetaDto,
|
||||
} from '../../../common/http/response';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import { CustomerDto, ListCustomersQueryDto } from './dto/customer.dto';
|
||||
import { CustomersService } from './customers.service';
|
||||
|
||||
export const CUSTOMER_PRIVILEGE_KEY = 'CONFIGURATION.CUSTOMER';
|
||||
|
||||
@ApiTags('customers')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('customers')
|
||||
export class CustomersReadController {
|
||||
constructor(private readonly customersService: CustomersService) {}
|
||||
|
||||
@Get()
|
||||
@Pagination()
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'view')
|
||||
@ApiOperation({ summary: 'List customers' })
|
||||
@ApiOkResponse({
|
||||
schema: {
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/CustomerDto' },
|
||||
},
|
||||
meta: { $ref: '#/components/schemas/PaginationMetaDto' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
list(
|
||||
@Query() query: ListCustomersQueryDto,
|
||||
): Promise<PaginationResponse<CustomerDto>> {
|
||||
return this.customersService.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'view')
|
||||
@ApiOperation({ summary: 'Get customer detail' })
|
||||
@ApiOkResponse({ type: CustomerDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<CustomerDto> {
|
||||
return this.customersService.findById(id);
|
||||
}
|
||||
}
|
||||
|
||||
void PaginationMetaDto;
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { CustomersWriteController } from './customers-write.controller';
|
||||
import { CustomersService } from './customers.service';
|
||||
|
||||
const createDto = {
|
||||
code: 'CUST_01',
|
||||
name: 'Acme Corp',
|
||||
phone: '+6281234567890',
|
||||
address: 'Jl Sudirman No 1',
|
||||
};
|
||||
|
||||
describe('CustomersWriteController', () => {
|
||||
let controller: CustomersWriteController;
|
||||
const service = {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateStatus: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
bulkDelete: jest.fn(),
|
||||
bulkUpdateStatus: jest.fn(),
|
||||
importCsv: jest.fn(),
|
||||
addContact: jest.fn(),
|
||||
updateContact: jest.fn(),
|
||||
deleteContact: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
controllers: [CustomersWriteController],
|
||||
providers: [{ provide: CustomersService, useValue: service }],
|
||||
}).compile();
|
||||
controller = moduleRef.get(CustomersWriteController);
|
||||
});
|
||||
|
||||
it('create passes dto fields and user id', async () => {
|
||||
service.create.mockResolvedValue({ id: 'cu-1' });
|
||||
await controller.create(createDto, 'user-1');
|
||||
expect(service.create).toHaveBeenCalledWith({
|
||||
...createDto,
|
||||
userId: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('update, updateStatus, and delete delegate', async () => {
|
||||
service.update.mockResolvedValue({ id: 'cu-1' });
|
||||
service.updateStatus.mockResolvedValue({ id: 'cu-1' });
|
||||
service.delete.mockResolvedValue(undefined);
|
||||
await controller.update('cu-1', { name: 'Acme Corp' }, 'user-1');
|
||||
await controller.updateStatus('cu-1', { status: 'active' }, 'user-1');
|
||||
await controller.delete('cu-1');
|
||||
expect(service.updateStatus).toHaveBeenCalledWith(
|
||||
'cu-1',
|
||||
'active',
|
||||
'user-1',
|
||||
);
|
||||
expect(service.delete).toHaveBeenCalledWith('cu-1');
|
||||
});
|
||||
|
||||
it('nested contact routes delegate', async () => {
|
||||
service.addContact.mockResolvedValue({ id: 'cu-1' });
|
||||
service.updateContact.mockResolvedValue({ id: 'cu-1' });
|
||||
service.deleteContact.mockResolvedValue(undefined);
|
||||
await controller.addContact('cu-1', { name: 'Ada Lovelace' }, 'user-1');
|
||||
await controller.updateContact(
|
||||
'cu-1',
|
||||
'ct-1',
|
||||
{ jobTitle: 'Buyer' },
|
||||
'user-1',
|
||||
);
|
||||
await controller.deleteContact('cu-1', 'ct-1');
|
||||
expect(service.addContact).toHaveBeenCalledWith(
|
||||
'cu-1',
|
||||
{ name: 'Ada Lovelace' },
|
||||
'user-1',
|
||||
);
|
||||
expect(service.updateContact).toHaveBeenCalledWith('cu-1', 'ct-1', {
|
||||
jobTitle: 'Buyer',
|
||||
userId: 'user-1',
|
||||
});
|
||||
expect(service.deleteContact).toHaveBeenCalledWith('cu-1', 'ct-1');
|
||||
});
|
||||
|
||||
it('bulk and import delegate', async () => {
|
||||
service.bulkDelete.mockResolvedValue({ deleted: 1 });
|
||||
service.bulkUpdateStatus.mockResolvedValue({ updated: 1 });
|
||||
service.importCsv.mockResolvedValue({ imported: 1 });
|
||||
await controller.bulkDelete({ ids: ['cu-1'] });
|
||||
await controller.bulkStatus(
|
||||
{ ids: ['cu-1'], status: 'archived' },
|
||||
'user-1',
|
||||
);
|
||||
await controller.importCsv(
|
||||
{ buffer: Buffer.from('code,name\nCUST_01,Acme') },
|
||||
'user-1',
|
||||
);
|
||||
expect(service.importCsv).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('importCsv uses empty string when file is missing', async () => {
|
||||
service.importCsv.mockResolvedValue({ imported: 0 });
|
||||
await controller.importCsv(undefined, 'user-1');
|
||||
expect(service.importCsv).toHaveBeenCalledWith('', 'user-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiCreatedResponse,
|
||||
ApiForbiddenResponse,
|
||||
ApiNoContentResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import { isAllowedCsvUpload } from './customer-fields';
|
||||
import { CUSTOMER_PRIVILEGE_KEY } from './customers-read.controller';
|
||||
import { CustomersService } from './customers.service';
|
||||
import {
|
||||
BulkIdsDto,
|
||||
BulkStatusDto,
|
||||
CreateCustomerContactDto,
|
||||
CreateCustomerDto,
|
||||
CustomerDto,
|
||||
UpdateCustomerContactDto,
|
||||
UpdateCustomerDto,
|
||||
UpdateCustomerStatusDto,
|
||||
} from './dto/customer.dto';
|
||||
|
||||
@ApiTags('customers')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('customers')
|
||||
export class CustomersWriteController {
|
||||
constructor(private readonly customersService: CustomersService) {}
|
||||
|
||||
@Post('import')
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'import')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: 1_048_576 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (!isAllowedCsvUpload(file)) {
|
||||
cb(new BadRequestException('Only CSV files are allowed'), false);
|
||||
return;
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
}),
|
||||
)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: { type: 'string', format: 'binary' },
|
||||
},
|
||||
required: ['file'],
|
||||
},
|
||||
})
|
||||
@ApiOperation({ summary: 'Import customers from CSV' })
|
||||
@ApiOkResponse({
|
||||
schema: { properties: { imported: { type: 'number' } } },
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
importCsv(
|
||||
@UploadedFile() file: { buffer?: Buffer } | undefined,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<{ imported: number }> {
|
||||
const csv = file?.buffer?.toString('utf8') ?? '';
|
||||
return this.customersService.importCsv(csv, userId);
|
||||
}
|
||||
|
||||
@Post('bulk-delete')
|
||||
@HttpCode(200)
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'delete')
|
||||
@ApiOperation({ summary: 'Bulk delete customers' })
|
||||
@ApiOkResponse({
|
||||
schema: { properties: { deleted: { type: 'number' } } },
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
bulkDelete(@Body() dto: BulkIdsDto): Promise<{ deleted: number }> {
|
||||
return this.customersService.bulkDelete(dto.ids);
|
||||
}
|
||||
|
||||
@Post('bulk-status')
|
||||
@HttpCode(200)
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Bulk update customer status' })
|
||||
@ApiOkResponse({
|
||||
schema: { properties: { updated: { type: 'number' } } },
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
bulkStatus(
|
||||
@Body() dto: BulkStatusDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<{ updated: number }> {
|
||||
return this.customersService.bulkUpdateStatus(dto.ids, dto.status, userId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'create')
|
||||
@ApiOperation({ summary: 'Create customer' })
|
||||
@ApiCreatedResponse({ type: CustomerDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
create(
|
||||
@Body() dto: CreateCustomerDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<CustomerDto> {
|
||||
return this.customersService.create({
|
||||
...dto,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
@Post(':id/contacts')
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Add a customer contact' })
|
||||
@ApiOkResponse({ type: CustomerDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
addContact(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: CreateCustomerContactDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<CustomerDto> {
|
||||
return this.customersService.addContact(id, dto, userId);
|
||||
}
|
||||
|
||||
@Patch(':id/contacts/:contactId')
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Update a customer contact' })
|
||||
@ApiOkResponse({ type: CustomerDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
updateContact(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('contactId', ParseUUIDPipe) contactId: string,
|
||||
@Body() dto: UpdateCustomerContactDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<CustomerDto> {
|
||||
return this.customersService.updateContact(id, contactId, {
|
||||
...dto,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id/contacts/:contactId')
|
||||
@HttpCode(204)
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Delete a customer contact' })
|
||||
@ApiNoContentResponse()
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
async deleteContact(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('contactId', ParseUUIDPipe) contactId: string,
|
||||
): Promise<void> {
|
||||
await this.customersService.deleteContact(id, contactId);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Update customer status' })
|
||||
@ApiOkResponse({ type: CustomerDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
updateStatus(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateCustomerStatusDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<CustomerDto> {
|
||||
return this.customersService.updateStatus(id, dto.status, userId);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Update customer (not status)' })
|
||||
@ApiOkResponse({ type: CustomerDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateCustomerDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<CustomerDto> {
|
||||
return this.customersService.update(id, {
|
||||
...dto,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'delete')
|
||||
@ApiOperation({ summary: 'Delete customer' })
|
||||
@ApiNoContentResponse()
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
async delete(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||
await this.customersService.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CustomersReadController } from './customers-read.controller';
|
||||
import { CustomersWriteController } from './customers-write.controller';
|
||||
import { CustomersRepository } from './customers.repository';
|
||||
import { CustomersService } from './customers.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CustomersReadController, CustomersWriteController],
|
||||
providers: [CustomersRepository, CustomersService],
|
||||
exports: [CustomersService],
|
||||
})
|
||||
export class CustomersModule {}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { DRIZZLE } from '../../../database/database.module';
|
||||
import { CustomersRepository } from './customers.repository';
|
||||
|
||||
describe('CustomersRepository', () => {
|
||||
let repository: CustomersRepository;
|
||||
|
||||
const limit = jest.fn();
|
||||
const orderBy = jest.fn();
|
||||
const offset = jest.fn();
|
||||
const where = jest.fn();
|
||||
const from = jest.fn();
|
||||
const select = jest.fn();
|
||||
const returning = jest.fn();
|
||||
const values = jest.fn();
|
||||
const insert = jest.fn();
|
||||
const set = jest.fn();
|
||||
const update = jest.fn();
|
||||
const del = jest.fn();
|
||||
const transaction = jest.fn();
|
||||
const $dynamic = jest.fn();
|
||||
|
||||
const db = {
|
||||
select,
|
||||
insert,
|
||||
update,
|
||||
delete: del,
|
||||
transaction,
|
||||
};
|
||||
|
||||
const row = {
|
||||
id: 'cu-1',
|
||||
code: 'CUST_01',
|
||||
name: 'Acme Corp',
|
||||
phone: '+6281234567890',
|
||||
address: 'Jl Sudirman No 1',
|
||||
latitude: -6.2,
|
||||
longitude: 106.8,
|
||||
nfcId: 'NFC-001',
|
||||
status: 'draft',
|
||||
createdAt: 1_700_000_000_000,
|
||||
updatedAt: 1_700_000_000_000,
|
||||
createdBy: 'user-1',
|
||||
updatedBy: 'user-1',
|
||||
};
|
||||
|
||||
const contactRow = {
|
||||
id: 'ct-1',
|
||||
customerId: 'cu-1',
|
||||
name: 'Jean Luc',
|
||||
jobTitle: 'Buyer',
|
||||
phone: '+6281234567891',
|
||||
mobilePhone: null,
|
||||
notes: 'Primary',
|
||||
};
|
||||
|
||||
const createInput = {
|
||||
code: 'CUST_01',
|
||||
name: 'Acme Corp',
|
||||
phone: PhoneNumber.create('+6281234567890'),
|
||||
address: 'Jl Sudirman No 1',
|
||||
userId: 'user-1',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
where.mockImplementation(() =>
|
||||
Object.assign(Promise.resolve([{ id: 'user-1', username: 'admin' }]), {
|
||||
limit,
|
||||
orderBy,
|
||||
returning,
|
||||
}),
|
||||
);
|
||||
orderBy.mockImplementation(() => ({ limit }));
|
||||
limit.mockImplementation(() => ({ offset }));
|
||||
offset.mockResolvedValue([row]);
|
||||
from.mockImplementation(() => ({
|
||||
where,
|
||||
$dynamic,
|
||||
}));
|
||||
$dynamic.mockReturnValue({ where });
|
||||
select.mockImplementation(() => ({ from }));
|
||||
values.mockReturnValue({ returning });
|
||||
insert.mockReturnValue({ values });
|
||||
set.mockReturnValue({ where });
|
||||
update.mockReturnValue({ set });
|
||||
del.mockReturnValue({ where });
|
||||
returning.mockResolvedValue([row]);
|
||||
transaction.mockImplementation((fn: (tx: typeof db) => unknown) => fn(db));
|
||||
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [CustomersRepository, { provide: DRIZZLE, useValue: db }],
|
||||
}).compile();
|
||||
repository = moduleRef.get(CustomersRepository);
|
||||
});
|
||||
|
||||
it('findById maps a row and contacts to domain', async () => {
|
||||
limit.mockResolvedValueOnce([row]);
|
||||
orderBy.mockResolvedValueOnce([contactRow]);
|
||||
const customer = await repository.findById('cu-1');
|
||||
expect(customer).toMatchObject({
|
||||
id: 'cu-1',
|
||||
code: 'CUST_01',
|
||||
createdBy: 'user-1',
|
||||
});
|
||||
expect(customer?.phone.value).toBe('+6281234567890');
|
||||
expect(customer?.status.value).toBe('draft');
|
||||
expect(customer?.contacts[0].name).toBe('Jean Luc');
|
||||
expect(customer?.contacts[0].phone?.value).toBe('+6281234567891');
|
||||
});
|
||||
|
||||
it('findById returns null when missing', async () => {
|
||||
limit.mockResolvedValueOnce([]);
|
||||
await expect(repository.findById('missing')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('list returns mapped rows and total without loading contacts', async () => {
|
||||
select
|
||||
.mockImplementationOnce(() => ({
|
||||
from: () => ({
|
||||
where: () => Promise.resolve([{ total: 1 }]),
|
||||
}),
|
||||
}))
|
||||
.mockImplementationOnce(() => ({
|
||||
from: () => ({
|
||||
$dynamic: () => ({
|
||||
where: () => ({
|
||||
orderBy: () => ({
|
||||
limit: () => ({
|
||||
offset: () => Promise.resolve([row]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
const result = await repository.list({
|
||||
name: 'Acme',
|
||||
code: 'CUST',
|
||||
search: 'sudirman',
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
});
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.data[0].code).toBe('CUST_01');
|
||||
expect(result.data[0].contacts).toEqual([]);
|
||||
});
|
||||
|
||||
it('create inserts and maps unique violations', async () => {
|
||||
returning.mockResolvedValue([row]);
|
||||
orderBy.mockResolvedValueOnce([]);
|
||||
const created = await repository.create(createInput);
|
||||
expect(created.code).toBe('CUST_01');
|
||||
|
||||
transaction.mockRejectedValueOnce({ code: '23505' });
|
||||
await expect(repository.create(createInput)).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
|
||||
transaction.mockRejectedValueOnce({
|
||||
code: '23505',
|
||||
constraint: 'customers_nfc_id_unique',
|
||||
});
|
||||
await expect(repository.create(createInput)).rejects.toMatchObject({
|
||||
message: 'Customer NFC ID already exists',
|
||||
});
|
||||
|
||||
transaction.mockRejectedValueOnce({
|
||||
cause: {
|
||||
code: '23505',
|
||||
constraint_name: 'customers_code_unique',
|
||||
},
|
||||
});
|
||||
await expect(repository.create(createInput)).rejects.toMatchObject({
|
||||
message: 'Customer code already exists',
|
||||
});
|
||||
});
|
||||
|
||||
it('create rethrows unknown errors', async () => {
|
||||
transaction.mockRejectedValue(new Error('db down'));
|
||||
await expect(repository.create(createInput)).rejects.toThrow('db down');
|
||||
});
|
||||
|
||||
it('createMany returns 0 for an empty batch', async () => {
|
||||
await expect(repository.createMany([])).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it('update throws when missing', async () => {
|
||||
limit.mockResolvedValueOnce([]);
|
||||
await expect(
|
||||
repository.update('missing', { userId: 'user-1' }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('updateStatus throws when missing', async () => {
|
||||
returning.mockResolvedValueOnce([]);
|
||||
await expect(
|
||||
repository.updateStatus('missing', Status.create('active'), 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('delete throws when missing', async () => {
|
||||
returning.mockResolvedValueOnce([]);
|
||||
await expect(repository.delete('missing')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('bulkUpdateStatus and bulkDelete return 0 for empty ids', async () => {
|
||||
await expect(
|
||||
repository.bulkUpdateStatus([], Status.create('active'), 'user-1'),
|
||||
).resolves.toBe(0);
|
||||
await expect(repository.bulkDelete([])).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it('addContact throws when customer is missing', async () => {
|
||||
limit.mockResolvedValueOnce([]);
|
||||
await expect(
|
||||
repository.addContact('missing', { name: 'Ada' }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('deleteContact throws when contact is missing', async () => {
|
||||
returning.mockResolvedValueOnce([]);
|
||||
await expect(
|
||||
repository.deleteContact('cu-1', 'ct-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('extendListQuery is a passthrough hook', () => {
|
||||
const qb = { join: true };
|
||||
expect(repository.extendListQuery(qb, { limit: 10, offset: 0 })).toBe(qb);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,539 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
|
||||
import { toOrderClauses } from '../../../common/http/response';
|
||||
import { attachAuditUsers } from '../../../database/load-user-refs';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||
import {
|
||||
customerContacts,
|
||||
customers,
|
||||
type CustomerContactRow,
|
||||
type CustomerRow,
|
||||
type NewCustomerRow,
|
||||
} from '../../../database/customers-table';
|
||||
import type {
|
||||
CreateCustomerInput,
|
||||
Customer,
|
||||
CustomerContact,
|
||||
CustomerContactInput,
|
||||
ListCustomersFilters,
|
||||
UpdateCustomerContactInput,
|
||||
UpdateCustomerInput,
|
||||
} from './customer';
|
||||
|
||||
const CUSTOMER_ORDER_COLUMNS = {
|
||||
id: customers.id,
|
||||
code: customers.code,
|
||||
name: customers.name,
|
||||
phone: customers.phone,
|
||||
address: customers.address,
|
||||
nfcId: customers.nfcId,
|
||||
status: customers.status,
|
||||
createdAt: customers.createdAt,
|
||||
updatedAt: customers.updatedAt,
|
||||
};
|
||||
|
||||
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete'>;
|
||||
|
||||
@Injectable()
|
||||
export class CustomersRepository {
|
||||
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||
|
||||
async list(
|
||||
filters: ListCustomersFilters,
|
||||
): Promise<{ data: Customer[]; total: number }> {
|
||||
const where = this.buildListWhere(filters);
|
||||
const totalRows = await this.db
|
||||
.select({ total: count() })
|
||||
.from(customers)
|
||||
.where(where);
|
||||
const totalRow = totalRows[0];
|
||||
|
||||
let qb = this.db.select().from(customers).$dynamic();
|
||||
qb = this.extendListQuery(qb, filters);
|
||||
const rows = await qb
|
||||
.where(where)
|
||||
.orderBy(
|
||||
...toOrderClauses(CUSTOMER_ORDER_COLUMNS, filters, [
|
||||
{ column: 'code', type: 'ASC' },
|
||||
]),
|
||||
)
|
||||
.limit(filters.limit)
|
||||
.offset(filters.offset);
|
||||
|
||||
return {
|
||||
data: await Promise.all(rows.map((row) => this.hydrate(row, []))),
|
||||
total: Number(totalRow?.total ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for modules to add joins/extra predicates without forking list.
|
||||
*/
|
||||
extendListQuery<T>(qb: T, filters: ListCustomersFilters): T {
|
||||
void filters;
|
||||
return qb;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Customer | null> {
|
||||
const rows: CustomerRow[] = await this.db
|
||||
.select()
|
||||
.from(customers)
|
||||
.where(eq(customers.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
const contacts = await this.selectContacts(this.db, id);
|
||||
return this.hydrate(row, contacts);
|
||||
}
|
||||
|
||||
async findByCode(code: string): Promise<Customer | null> {
|
||||
const rows: CustomerRow[] = await this.db
|
||||
.select()
|
||||
.from(customers)
|
||||
.where(eq(customers.code, code))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
const contacts = await this.selectContacts(this.db, row.id);
|
||||
return this.hydrate(row, contacts);
|
||||
}
|
||||
|
||||
async create(input: CreateCustomerInput): Promise<Customer> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const status = input.status ?? Status.create(Status.DEFAULT);
|
||||
try {
|
||||
return await this.db.transaction(async (tx) => {
|
||||
const inserted = await tx
|
||||
.insert(customers)
|
||||
.values(this.toInsertValues(input, status, now, input.userId))
|
||||
.returning();
|
||||
const row = inserted[0];
|
||||
await this.replaceContacts(tx, row.id, input.contacts ?? []);
|
||||
const contacts = await this.selectContacts(tx, row.id);
|
||||
return this.hydrate(row, contacts);
|
||||
});
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createMany(inputs: CreateCustomerInput[]): Promise<number> {
|
||||
if (inputs.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
try {
|
||||
await this.db.transaction(async (tx) => {
|
||||
for (const input of inputs) {
|
||||
const status = input.status ?? Status.create(Status.DEFAULT);
|
||||
const inserted = await tx
|
||||
.insert(customers)
|
||||
.values(this.toInsertValues(input, status, now, input.userId))
|
||||
.returning();
|
||||
const row = inserted[0];
|
||||
await this.replaceContacts(tx, row.id, input.contacts ?? []);
|
||||
}
|
||||
});
|
||||
return inputs.length;
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, input: UpdateCustomerInput): Promise<Customer> {
|
||||
const existing = await this.findById(id);
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Customer not found');
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
try {
|
||||
return await this.db.transaction(async (tx) => {
|
||||
const values: Partial<NewCustomerRow> = {
|
||||
code: input.code ?? existing.code,
|
||||
name: input.name ?? existing.name,
|
||||
phone: input.phone?.value ?? existing.phone.value,
|
||||
address: input.address ?? existing.address,
|
||||
latitude:
|
||||
input.latitude !== undefined ? input.latitude : existing.latitude,
|
||||
longitude:
|
||||
input.longitude !== undefined
|
||||
? input.longitude
|
||||
: existing.longitude,
|
||||
nfcId: input.nfcId !== undefined ? input.nfcId : existing.nfcId,
|
||||
updatedAt: now.value,
|
||||
updatedBy: input.userId,
|
||||
};
|
||||
const updated = await tx
|
||||
.update(customers)
|
||||
.set(values)
|
||||
.where(eq(customers.id, id))
|
||||
.returning();
|
||||
const row = updated[0];
|
||||
if (!row) {
|
||||
throw new NotFoundException('Customer not found');
|
||||
}
|
||||
if (input.contacts !== undefined) {
|
||||
await this.replaceContacts(tx, id, input.contacts);
|
||||
}
|
||||
const contacts = await this.selectContacts(tx, id);
|
||||
return this.hydrate(row, contacts);
|
||||
});
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
status: Status,
|
||||
userId: string,
|
||||
): Promise<Customer> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const updated = await this.db
|
||||
.update(customers)
|
||||
.set({
|
||||
status: status.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(eq(customers.id, id))
|
||||
.returning();
|
||||
const row = updated[0];
|
||||
if (!row) {
|
||||
throw new NotFoundException('Customer not found');
|
||||
}
|
||||
const contacts = await this.selectContacts(this.db, id);
|
||||
return this.hydrate(row, contacts);
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
ids: string[],
|
||||
status: Status,
|
||||
userId: string,
|
||||
): Promise<number> {
|
||||
if (ids.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const rows = await this.db
|
||||
.update(customers)
|
||||
.set({
|
||||
status: status.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(inArray(customers.id, ids))
|
||||
.returning({ id: customers.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const deleted = await this.db
|
||||
.delete(customers)
|
||||
.where(eq(customers.id, id))
|
||||
.returning({ id: customers.id });
|
||||
if (deleted.length === 0) {
|
||||
throw new NotFoundException('Customer not found');
|
||||
}
|
||||
}
|
||||
|
||||
async bulkDelete(ids: string[]): Promise<number> {
|
||||
if (ids.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const deleted = await this.db
|
||||
.delete(customers)
|
||||
.where(inArray(customers.id, ids))
|
||||
.returning({ id: customers.id });
|
||||
return deleted.length;
|
||||
}
|
||||
|
||||
async addContact(
|
||||
customerId: string,
|
||||
input: CustomerContactInput,
|
||||
userId: string,
|
||||
): Promise<Customer> {
|
||||
const existing = await this.findById(customerId);
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Customer not found');
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
await this.db.insert(customerContacts).values({
|
||||
customerId,
|
||||
name: input.name,
|
||||
jobTitle: input.jobTitle ?? null,
|
||||
phone: input.phone?.value ?? null,
|
||||
mobilePhone: input.mobilePhone?.value ?? null,
|
||||
notes: input.notes ?? null,
|
||||
});
|
||||
await this.touchCustomer(customerId, userId, now);
|
||||
const found = await this.findById(customerId);
|
||||
if (!found) {
|
||||
throw new NotFoundException('Customer not found');
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
async updateContact(
|
||||
customerId: string,
|
||||
contactId: string,
|
||||
input: UpdateCustomerContactInput,
|
||||
): Promise<Customer> {
|
||||
const existing = await this.findById(customerId);
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Customer not found');
|
||||
}
|
||||
const current = existing.contacts.find((c) => c.id === contactId);
|
||||
if (!current) {
|
||||
throw new NotFoundException('Contact not found');
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const updated = await this.db
|
||||
.update(customerContacts)
|
||||
.set({
|
||||
name: input.name ?? current.name,
|
||||
jobTitle:
|
||||
input.jobTitle !== undefined ? input.jobTitle : current.jobTitle,
|
||||
phone:
|
||||
input.phone !== undefined
|
||||
? (input.phone?.value ?? null)
|
||||
: (current.phone?.value ?? null),
|
||||
mobilePhone:
|
||||
input.mobilePhone !== undefined
|
||||
? (input.mobilePhone?.value ?? null)
|
||||
: (current.mobilePhone?.value ?? null),
|
||||
notes: input.notes !== undefined ? input.notes : current.notes,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(customerContacts.id, contactId),
|
||||
eq(customerContacts.customerId, customerId),
|
||||
),
|
||||
)
|
||||
.returning({ id: customerContacts.id });
|
||||
if (updated.length === 0) {
|
||||
throw new NotFoundException('Contact not found');
|
||||
}
|
||||
await this.touchCustomer(customerId, input.userId, now);
|
||||
const found = await this.findById(customerId);
|
||||
if (!found) {
|
||||
throw new NotFoundException('Customer not found');
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
async deleteContact(customerId: string, contactId: string): Promise<void> {
|
||||
const deleted = await this.db
|
||||
.delete(customerContacts)
|
||||
.where(
|
||||
and(
|
||||
eq(customerContacts.id, contactId),
|
||||
eq(customerContacts.customerId, customerId),
|
||||
),
|
||||
)
|
||||
.returning({ id: customerContacts.id });
|
||||
if (deleted.length === 0) {
|
||||
throw new NotFoundException('Contact not found');
|
||||
}
|
||||
}
|
||||
|
||||
private async touchCustomer(
|
||||
customerId: string,
|
||||
userId: string,
|
||||
now: DateTime,
|
||||
): Promise<void> {
|
||||
await this.db
|
||||
.update(customers)
|
||||
.set({
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(eq(customers.id, customerId));
|
||||
}
|
||||
|
||||
private async selectContacts(
|
||||
executor: QueryExecutor,
|
||||
customerId: string,
|
||||
): Promise<CustomerContactRow[]> {
|
||||
return executor
|
||||
.select()
|
||||
.from(customerContacts)
|
||||
.where(eq(customerContacts.customerId, customerId))
|
||||
.orderBy(asc(customerContacts.name));
|
||||
}
|
||||
|
||||
private async replaceContacts(
|
||||
executor: QueryExecutor,
|
||||
customerId: string,
|
||||
contacts: readonly CustomerContactInput[],
|
||||
): Promise<void> {
|
||||
await executor
|
||||
.delete(customerContacts)
|
||||
.where(eq(customerContacts.customerId, customerId));
|
||||
if (contacts.length === 0) {
|
||||
return;
|
||||
}
|
||||
await executor.insert(customerContacts).values(
|
||||
contacts.map((contact) => ({
|
||||
customerId,
|
||||
name: contact.name,
|
||||
jobTitle: contact.jobTitle ?? null,
|
||||
phone: contact.phone?.value ?? null,
|
||||
mobilePhone: contact.mobilePhone?.value ?? null,
|
||||
notes: contact.notes ?? null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
private buildListWhere(filters: ListCustomersFilters): SQL | undefined {
|
||||
const parts: SQL[] = [];
|
||||
if (filters.code) {
|
||||
parts.push(ilike(customers.code, `%${filters.code}%`));
|
||||
}
|
||||
if (filters.name) {
|
||||
parts.push(ilike(customers.name, `%${filters.name}%`));
|
||||
}
|
||||
if (filters.phone) {
|
||||
parts.push(ilike(customers.phone, `%${filters.phone}%`));
|
||||
}
|
||||
if (filters.address) {
|
||||
parts.push(ilike(customers.address, `%${filters.address}%`));
|
||||
}
|
||||
if (filters.nfcId) {
|
||||
parts.push(eq(customers.nfcId, filters.nfcId));
|
||||
}
|
||||
if (filters.status) {
|
||||
parts.push(eq(customers.status, filters.status));
|
||||
}
|
||||
if (filters.search) {
|
||||
const search = or(
|
||||
ilike(customers.code, `%${filters.search}%`),
|
||||
ilike(customers.name, `%${filters.search}%`),
|
||||
ilike(customers.address, `%${filters.search}%`),
|
||||
);
|
||||
if (search) {
|
||||
parts.push(search);
|
||||
}
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return parts.length === 1 ? parts[0] : and(...parts);
|
||||
}
|
||||
|
||||
private toInsertValues(
|
||||
input: CreateCustomerInput,
|
||||
status: Status,
|
||||
now: DateTime,
|
||||
userId: string,
|
||||
) {
|
||||
return {
|
||||
code: input.code,
|
||||
name: input.name,
|
||||
phone: input.phone.value,
|
||||
address: input.address,
|
||||
latitude: input.latitude ?? null,
|
||||
longitude: input.longitude ?? null,
|
||||
nfcId: input.nfcId ?? null,
|
||||
status: status.value,
|
||||
createdAt: now.value,
|
||||
updatedAt: now.value,
|
||||
createdBy: userId,
|
||||
updatedBy: userId,
|
||||
};
|
||||
}
|
||||
|
||||
private async hydrate(
|
||||
row: CustomerRow,
|
||||
contactRows: CustomerContactRow[],
|
||||
): Promise<Customer> {
|
||||
const [item] = await attachAuditUsers(this.db, [
|
||||
this.toDomain(row, contactRows),
|
||||
]);
|
||||
return item;
|
||||
}
|
||||
|
||||
private toDomain(row: CustomerRow, contactRows: CustomerContactRow[]) {
|
||||
return {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
phone: PhoneNumber.create(row.phone),
|
||||
address: row.address,
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
nfcId: row.nfcId,
|
||||
contacts: contactRows.map((contact) => this.toContactDomain(contact)),
|
||||
status: Status.create(row.status),
|
||||
createdAt: DateTime.fromUnixMs(row.createdAt),
|
||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
private toContactDomain(row: CustomerContactRow): CustomerContact {
|
||||
return {
|
||||
id: row.id,
|
||||
customerId: row.customerId,
|
||||
name: row.name,
|
||||
jobTitle: row.jobTitle,
|
||||
phone: row.phone ? PhoneNumber.create(row.phone) : null,
|
||||
mobilePhone: row.mobilePhone ? PhoneNumber.create(row.mobilePhone) : null,
|
||||
notes: row.notes,
|
||||
};
|
||||
}
|
||||
|
||||
private rethrowConstraintViolation(error: unknown): never {
|
||||
if (error instanceof NotFoundException) {
|
||||
throw error;
|
||||
}
|
||||
const err = this.unwrapDbError(error);
|
||||
if (err.code === '23505') {
|
||||
const constraint = err.constraint ?? '';
|
||||
if (constraint.includes('nfc')) {
|
||||
throw new ConflictException('Customer NFC ID already exists');
|
||||
}
|
||||
throw new ConflictException('Customer code already exists');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
private unwrapDbError(error: unknown): {
|
||||
code?: string;
|
||||
constraint?: string;
|
||||
} {
|
||||
let current: unknown = error;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (!current || typeof current !== 'object') {
|
||||
break;
|
||||
}
|
||||
const obj = current as {
|
||||
code?: string;
|
||||
constraint?: string;
|
||||
constraint_name?: string;
|
||||
cause?: unknown;
|
||||
};
|
||||
if (obj.code === '23505' || obj.code === '23503') {
|
||||
return {
|
||||
code: obj.code,
|
||||
constraint: obj.constraint ?? obj.constraint_name,
|
||||
};
|
||||
}
|
||||
current = obj.cause;
|
||||
}
|
||||
return error as { code?: string; constraint?: string };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import type { Customer } from './customer';
|
||||
import { CustomersRepository } from './customers.repository';
|
||||
import { CustomersService } from './customers.service';
|
||||
|
||||
describe('CustomersService', () => {
|
||||
let service: CustomersService;
|
||||
let repository: jest.Mocked<
|
||||
Pick<
|
||||
CustomersRepository,
|
||||
| 'list'
|
||||
| 'findById'
|
||||
| 'create'
|
||||
| 'createMany'
|
||||
| 'update'
|
||||
| 'updateStatus'
|
||||
| 'bulkUpdateStatus'
|
||||
| 'delete'
|
||||
| 'bulkDelete'
|
||||
| 'addContact'
|
||||
| 'updateContact'
|
||||
| 'deleteContact'
|
||||
>
|
||||
>;
|
||||
|
||||
const now = DateTime.fromUnixMs(1_700_000_000_000);
|
||||
const sample: Customer = {
|
||||
id: 'cu-1',
|
||||
code: 'CUST_01',
|
||||
name: 'Acme Corp',
|
||||
phone: PhoneNumber.create('+6281234567890'),
|
||||
address: 'Jl Sudirman No 1',
|
||||
latitude: -6.2,
|
||||
longitude: 106.8,
|
||||
nfcId: 'NFC-001',
|
||||
contacts: [
|
||||
{
|
||||
id: 'ct-1',
|
||||
customerId: 'cu-1',
|
||||
name: 'Jean Luc',
|
||||
jobTitle: 'Buyer',
|
||||
phone: PhoneNumber.create('+6281234567891'),
|
||||
mobilePhone: null,
|
||||
notes: 'Primary',
|
||||
},
|
||||
],
|
||||
status: Status.create('draft'),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: 'user-1',
|
||||
createdByUser: { id: 'user-1', username: 'admin' },
|
||||
updatedByUser: { id: 'user-1', username: 'admin' },
|
||||
updatedBy: 'user-1',
|
||||
};
|
||||
|
||||
const createInput = {
|
||||
code: 'CUST_01',
|
||||
name: 'Acme Corp',
|
||||
phone: '+6281234567890',
|
||||
address: 'Jl Sudirman No 1',
|
||||
userId: 'user-1',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
repository = {
|
||||
list: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
create: jest.fn(),
|
||||
createMany: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateStatus: jest.fn(),
|
||||
bulkUpdateStatus: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
bulkDelete: jest.fn(),
|
||||
addContact: jest.fn(),
|
||||
updateContact: jest.fn(),
|
||||
deleteContact: jest.fn(),
|
||||
};
|
||||
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
CustomersService,
|
||||
{ provide: CustomersRepository, useValue: repository },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = moduleRef.get(CustomersService);
|
||||
});
|
||||
|
||||
it('list maps visible fields without contacts', async () => {
|
||||
repository.list.mockResolvedValue({ data: [sample], total: 1 });
|
||||
const result = await service.list({ page: 1, limit: 10 });
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.data[0]).toMatchObject({
|
||||
id: 'cu-1',
|
||||
code: 'CUST_01',
|
||||
name: 'Acme Corp',
|
||||
phone: '+6281234567890',
|
||||
status: 'draft',
|
||||
});
|
||||
expect(result.data[0]).not.toHaveProperty('contacts');
|
||||
expect(service.visibleFields).toContain('phone');
|
||||
});
|
||||
|
||||
it('findById throws when missing', async () => {
|
||||
repository.findById.mockResolvedValue(null);
|
||||
await expect(service.findById('missing')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('findById returns mapped item with contacts', async () => {
|
||||
repository.findById.mockResolvedValue(sample);
|
||||
const result = await service.findById('cu-1');
|
||||
expect(result.id).toBe('cu-1');
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].name).toBe('Jean Luc');
|
||||
expect(result.contacts[0].phone).toBe('+6281234567891');
|
||||
});
|
||||
|
||||
it('create defaults status to draft and maps contacts', async () => {
|
||||
repository.create.mockResolvedValue(sample);
|
||||
await service.create({
|
||||
...createInput,
|
||||
contacts: [{ name: 'Jean Luc', phone: '+6281234567891' }],
|
||||
});
|
||||
const arg = repository.create.mock.calls[0][0];
|
||||
expect(arg.status?.value).toBe('draft');
|
||||
expect(arg.phone.value).toBe('+6281234567890');
|
||||
expect(arg.contacts?.[0].name).toBe('Jean Luc');
|
||||
expect(arg.contacts?.[0].phone?.value).toBe('+6281234567891');
|
||||
});
|
||||
|
||||
it('create rejects invalid phone without echoing input', async () => {
|
||||
await expect(
|
||||
service.create({ ...createInput, phone: '081234567890' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('create rejects invalid name or code', async () => {
|
||||
await expect(
|
||||
service.create({ ...createInput, name: 'Acme1' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(
|
||||
service.create({ ...createInput, code: 'CUST 01' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('create rejects out of range coordinates', async () => {
|
||||
await expect(
|
||||
service.create({ ...createInput, latitude: 91 }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(
|
||||
service.create({ ...createInput, longitude: 181 }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('update rejects status field', async () => {
|
||||
await expect(
|
||||
service.update('cu-1', { status: 'active', userId: 'user-1' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('update replaces contacts when contacts is sent', async () => {
|
||||
repository.update.mockResolvedValue(sample);
|
||||
await service.update('cu-1', {
|
||||
contacts: [{ name: 'Ada Lovelace' }],
|
||||
userId: 'user-1',
|
||||
});
|
||||
expect(repository.update).toHaveBeenCalledWith(
|
||||
'cu-1',
|
||||
expect.objectContaining({
|
||||
contacts: [expect.objectContaining({ name: 'Ada Lovelace' })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('update leaves contacts unchanged when omitted', async () => {
|
||||
repository.update.mockResolvedValue(sample);
|
||||
await service.update('cu-1', { name: 'Acme Corp', userId: 'user-1' });
|
||||
expect(repository.update).toHaveBeenCalledWith(
|
||||
'cu-1',
|
||||
expect.objectContaining({
|
||||
name: 'Acme Corp',
|
||||
contacts: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('updateStatus updates via repository', async () => {
|
||||
repository.updateStatus.mockResolvedValue(sample);
|
||||
await service.updateStatus('cu-1', 'active', 'user-1');
|
||||
expect(repository.updateStatus).toHaveBeenCalledWith(
|
||||
'cu-1',
|
||||
expect.objectContaining({ value: 'active' }),
|
||||
'user-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('delete, bulkDelete, and bulkUpdateStatus delegate', async () => {
|
||||
repository.delete.mockResolvedValue(undefined);
|
||||
repository.bulkDelete.mockResolvedValue(2);
|
||||
repository.bulkUpdateStatus.mockResolvedValue(2);
|
||||
await service.delete('cu-1');
|
||||
await expect(service.bulkDelete(['a', 'b'])).resolves.toEqual({
|
||||
deleted: 2,
|
||||
});
|
||||
await expect(
|
||||
service.bulkUpdateStatus(['a', 'b'], 'archived', 'user-1'),
|
||||
).resolves.toEqual({ updated: 2 });
|
||||
});
|
||||
|
||||
it('addContact and updateContact validate phones', async () => {
|
||||
repository.addContact.mockResolvedValue(sample);
|
||||
repository.updateContact.mockResolvedValue(sample);
|
||||
await service.addContact('cu-1', { name: 'Ada Lovelace' }, 'user-1');
|
||||
expect(repository.addContact).toHaveBeenCalledWith(
|
||||
'cu-1',
|
||||
expect.objectContaining({ name: 'Ada Lovelace' }),
|
||||
'user-1',
|
||||
);
|
||||
await expect(
|
||||
service.addContact('cu-1', { name: 'Ada', phone: '0812' }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('deleteContact delegates', async () => {
|
||||
repository.deleteContact.mockResolvedValue(undefined);
|
||||
await service.deleteContact('cu-1', 'ct-1');
|
||||
expect(repository.deleteContact).toHaveBeenCalledWith('cu-1', 'ct-1');
|
||||
});
|
||||
|
||||
it('importCsv imports valid rows without contacts', async () => {
|
||||
repository.createMany.mockResolvedValue(1);
|
||||
const csv =
|
||||
'code,name,phone,address,status\n' +
|
||||
'CUST_01,Acme Corp,+6281234567890,Jl Sudirman No 1,draft';
|
||||
const result = await service.importCsv(csv, 'user-1');
|
||||
expect(result.imported).toBe(1);
|
||||
expect(repository.createMany).toHaveBeenCalledTimes(1);
|
||||
expect(repository.createMany.mock.calls[0][0][0].contacts).toEqual([]);
|
||||
});
|
||||
|
||||
it('importCsv fails the batch on invalid phone', async () => {
|
||||
const csv =
|
||||
'code,name,phone,address\n' + 'CUST_01,Acme Corp,081234,Jl Sudirman No 1';
|
||||
await expect(service.importCsv(csv, 'user-1')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(repository.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('importCsv rejects empty and headerless files', async () => {
|
||||
await expect(service.importCsv('', 'user-1')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(
|
||||
service.importCsv('code,name\nCUST_01,Acme', 'user-1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('importCsv rejects oversized files', async () => {
|
||||
const huge = [
|
||||
'code,name,phone,address',
|
||||
...Array.from(
|
||||
{ length: 501 },
|
||||
(_, i) => `C${i},Acme Corp,+6281234567890,Jl Sudirman`,
|
||||
),
|
||||
].join('\n');
|
||||
await expect(service.importCsv(huge, 'user-1')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,526 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { PaginationResponse } from '../../../common/http/response';
|
||||
import { pickUserRelation, toListPage } from '../../../common/http/response';
|
||||
import { InvalidPhoneNumberError } from '../../../common/value-objects/phone-number/invalid-phone-number.error';
|
||||
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import type {
|
||||
CreateCustomerInput,
|
||||
Customer,
|
||||
CustomerContact,
|
||||
CustomerContactInput,
|
||||
UpdateCustomerContactInput,
|
||||
UpdateCustomerInput,
|
||||
} from './customer';
|
||||
import {
|
||||
isValidContactJobTitle,
|
||||
isValidContactName,
|
||||
isValidContactNotes,
|
||||
isValidCustomerAddress,
|
||||
isValidCustomerCode,
|
||||
isValidCustomerName,
|
||||
isValidLatitude,
|
||||
isValidLongitude,
|
||||
isValidNfcId,
|
||||
parseCsvRecord,
|
||||
} from './customer-fields';
|
||||
import { CustomersRepository } from './customers.repository';
|
||||
|
||||
export type ListCustomersQuery = {
|
||||
readonly code?: string;
|
||||
readonly name?: string;
|
||||
readonly phone?: string;
|
||||
readonly address?: string;
|
||||
readonly nfcId?: string;
|
||||
readonly status?: string;
|
||||
readonly search?: string;
|
||||
readonly orderBy?: string;
|
||||
readonly orderType?: string;
|
||||
readonly page?: number;
|
||||
readonly limit?: number;
|
||||
readonly offset?: number;
|
||||
};
|
||||
|
||||
export type ContactBody = {
|
||||
readonly name: string;
|
||||
readonly jobTitle?: string | null;
|
||||
readonly phone?: string | null;
|
||||
readonly mobilePhone?: string | null;
|
||||
readonly notes?: string | null;
|
||||
};
|
||||
|
||||
const VISIBLE_FIELDS = [
|
||||
'id',
|
||||
'code',
|
||||
'name',
|
||||
'phone',
|
||||
'address',
|
||||
'latitude',
|
||||
'longitude',
|
||||
'nfcId',
|
||||
'status',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'createdBy',
|
||||
'updatedBy',
|
||||
] as const;
|
||||
|
||||
const CSV_REQUIRED_HEADERS = ['code', 'name', 'phone', 'address'] as const;
|
||||
|
||||
@Injectable()
|
||||
export class CustomersService {
|
||||
constructor(private readonly customersRepository: CustomersRepository) {}
|
||||
|
||||
async list(
|
||||
query: ListCustomersQuery,
|
||||
): Promise<PaginationResponse<ReturnType<CustomersService['toListItem']>>> {
|
||||
const page = toListPage(query);
|
||||
const { data, total } = await this.customersRepository.list({
|
||||
code: query.code,
|
||||
name: query.name,
|
||||
phone: query.phone,
|
||||
address: query.address,
|
||||
nfcId: query.nfcId,
|
||||
status: query.status,
|
||||
search: query.search,
|
||||
orderBy: query.orderBy,
|
||||
orderType: query.orderType,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
});
|
||||
return {
|
||||
data: data.map((item) => this.toListItem(item)),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async findById(
|
||||
id: string,
|
||||
): Promise<ReturnType<CustomersService['toDetail']>> {
|
||||
const customer = await this.customersRepository.findById(id);
|
||||
if (!customer) {
|
||||
throw new NotFoundException('Customer not found');
|
||||
}
|
||||
return this.toDetail(customer);
|
||||
}
|
||||
|
||||
async findByCode(
|
||||
code: string,
|
||||
): Promise<ReturnType<CustomersService['toDetail']>> {
|
||||
const customer = await this.customersRepository.findByCode(code);
|
||||
if (!customer) {
|
||||
throw new NotFoundException('Customer not found');
|
||||
}
|
||||
return this.toDetail(customer);
|
||||
}
|
||||
|
||||
async create(input: {
|
||||
code: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
nfcId?: string | null;
|
||||
status?: string;
|
||||
contacts?: ContactBody[];
|
||||
userId: string;
|
||||
}): Promise<ReturnType<CustomersService['toDetail']>> {
|
||||
const created = await this.customersRepository.create(
|
||||
this.toCreateInput(input),
|
||||
);
|
||||
return this.toDetail(created);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: {
|
||||
code?: string;
|
||||
name?: string;
|
||||
phone?: string;
|
||||
address?: string;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
nfcId?: string | null;
|
||||
contacts?: ContactBody[];
|
||||
status?: unknown;
|
||||
userId: string;
|
||||
},
|
||||
): Promise<ReturnType<CustomersService['toDetail']>> {
|
||||
if (input.status !== undefined) {
|
||||
throw new BadRequestException('status cannot be updated via PATCH');
|
||||
}
|
||||
const payload: UpdateCustomerInput = {
|
||||
code: input.code !== undefined ? this.assertCode(input.code) : undefined,
|
||||
name: input.name !== undefined ? this.assertName(input.name) : undefined,
|
||||
phone:
|
||||
input.phone !== undefined ? this.assertPhone(input.phone) : undefined,
|
||||
address:
|
||||
input.address !== undefined
|
||||
? this.assertAddress(input.address)
|
||||
: undefined,
|
||||
latitude:
|
||||
input.latitude !== undefined
|
||||
? this.assertLatitude(input.latitude)
|
||||
: undefined,
|
||||
longitude:
|
||||
input.longitude !== undefined
|
||||
? this.assertLongitude(input.longitude)
|
||||
: undefined,
|
||||
nfcId:
|
||||
input.nfcId !== undefined ? this.assertNfcId(input.nfcId) : undefined,
|
||||
contacts:
|
||||
input.contacts !== undefined
|
||||
? input.contacts.map((contact) => this.assertContact(contact))
|
||||
: undefined,
|
||||
userId: input.userId,
|
||||
};
|
||||
const updated = await this.customersRepository.update(id, payload);
|
||||
return this.toDetail(updated);
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
statusRaw: string,
|
||||
userId: string,
|
||||
): Promise<ReturnType<CustomersService['toDetail']>> {
|
||||
const status = Status.create(statusRaw);
|
||||
const updated = await this.customersRepository.updateStatus(
|
||||
id,
|
||||
status,
|
||||
userId,
|
||||
);
|
||||
return this.toDetail(updated);
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
ids: string[],
|
||||
statusRaw: string,
|
||||
userId: string,
|
||||
): Promise<{ updated: number }> {
|
||||
const status = Status.create(statusRaw);
|
||||
const updated = await this.customersRepository.bulkUpdateStatus(
|
||||
ids,
|
||||
status,
|
||||
userId,
|
||||
);
|
||||
return { updated };
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.customersRepository.delete(id);
|
||||
}
|
||||
|
||||
async bulkDelete(ids: string[]): Promise<{ deleted: number }> {
|
||||
const deleted = await this.customersRepository.bulkDelete(ids);
|
||||
return { deleted };
|
||||
}
|
||||
|
||||
async addContact(
|
||||
customerId: string,
|
||||
body: ContactBody,
|
||||
userId: string,
|
||||
): Promise<ReturnType<CustomersService['toDetail']>> {
|
||||
const updated = await this.customersRepository.addContact(
|
||||
customerId,
|
||||
this.assertContact(body),
|
||||
userId,
|
||||
);
|
||||
return this.toDetail(updated);
|
||||
}
|
||||
|
||||
async updateContact(
|
||||
customerId: string,
|
||||
contactId: string,
|
||||
body: Partial<ContactBody> & { userId: string },
|
||||
): Promise<ReturnType<CustomersService['toDetail']>> {
|
||||
const payload: UpdateCustomerContactInput = {
|
||||
name:
|
||||
body.name !== undefined ? this.assertContactName(body.name) : undefined,
|
||||
jobTitle:
|
||||
body.jobTitle !== undefined
|
||||
? this.assertOptionalJobTitle(body.jobTitle)
|
||||
: undefined,
|
||||
phone:
|
||||
body.phone !== undefined
|
||||
? this.assertOptionalPhone(body.phone)
|
||||
: undefined,
|
||||
mobilePhone:
|
||||
body.mobilePhone !== undefined
|
||||
? this.assertOptionalPhone(body.mobilePhone)
|
||||
: undefined,
|
||||
notes:
|
||||
body.notes !== undefined
|
||||
? this.assertOptionalNotes(body.notes)
|
||||
: undefined,
|
||||
userId: body.userId,
|
||||
};
|
||||
const updated = await this.customersRepository.updateContact(
|
||||
customerId,
|
||||
contactId,
|
||||
payload,
|
||||
);
|
||||
return this.toDetail(updated);
|
||||
}
|
||||
|
||||
async deleteContact(customerId: string, contactId: string): Promise<void> {
|
||||
await this.customersRepository.deleteContact(customerId, contactId);
|
||||
}
|
||||
|
||||
async importCsv(csv: string, userId: string): Promise<{ imported: number }> {
|
||||
const rawLines = csv.split(/\r?\n/);
|
||||
const filled = rawLines
|
||||
.map((line, index) => ({ line: line.trim(), lineNo: index + 1 }))
|
||||
.filter((entry) => entry.line.length > 0);
|
||||
if (filled.length === 0) {
|
||||
throw new BadRequestException('CSV is empty');
|
||||
}
|
||||
if (filled.length > 501) {
|
||||
throw new BadRequestException('CSV exceeds maximum of 500 data rows');
|
||||
}
|
||||
|
||||
const header = parseCsvRecord(filled[0].line).map((h) =>
|
||||
h.trim().toLowerCase(),
|
||||
);
|
||||
const missing = CSV_REQUIRED_HEADERS.filter((h) => header.indexOf(h) < 0);
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException('CSV must include required headers');
|
||||
}
|
||||
|
||||
const idx = (key: string) => header.indexOf(key);
|
||||
const errors: string[] = [];
|
||||
const rows: CreateCustomerInput[] = [];
|
||||
for (let i = 1; i < filled.length; i++) {
|
||||
const cols = parseCsvRecord(filled[i].line);
|
||||
const rowNum = filled[i].lineNo;
|
||||
try {
|
||||
const statusRaw = idx('status') >= 0 ? cols[idx('status')] : '';
|
||||
const latitudeRaw =
|
||||
idx('latitude') >= 0 ? cols[idx('latitude')] : undefined;
|
||||
const longitudeRaw =
|
||||
idx('longitude') >= 0 ? cols[idx('longitude')] : undefined;
|
||||
const nfcRaw = idx('nfcid') >= 0 ? cols[idx('nfcid')] : undefined;
|
||||
rows.push(
|
||||
this.toCreateInput({
|
||||
code: cols[idx('code')] ?? '',
|
||||
name: cols[idx('name')] ?? '',
|
||||
phone: cols[idx('phone')] ?? '',
|
||||
address: cols[idx('address')] ?? '',
|
||||
latitude:
|
||||
latitudeRaw === undefined || latitudeRaw === ''
|
||||
? undefined
|
||||
: Number(latitudeRaw),
|
||||
longitude:
|
||||
longitudeRaw === undefined || longitudeRaw === ''
|
||||
? undefined
|
||||
: Number(longitudeRaw),
|
||||
nfcId: nfcRaw || undefined,
|
||||
status: statusRaw || undefined,
|
||||
userId,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
const reason =
|
||||
error instanceof BadRequestException ? error.message : 'invalid data';
|
||||
errors.push(`row ${rowNum}: ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new BadRequestException({
|
||||
message: 'CSV validation failed',
|
||||
errors,
|
||||
});
|
||||
}
|
||||
|
||||
await this.customersRepository.createMany(rows);
|
||||
return { imported: rows.length };
|
||||
}
|
||||
|
||||
toListItem(customer: Customer) {
|
||||
return {
|
||||
id: customer.id,
|
||||
code: customer.code,
|
||||
name: customer.name,
|
||||
phone: customer.phone.value,
|
||||
address: customer.address,
|
||||
latitude: customer.latitude,
|
||||
longitude: customer.longitude,
|
||||
nfcId: customer.nfcId,
|
||||
status: customer.status.value,
|
||||
createdAt: customer.createdAt.value,
|
||||
updatedAt: customer.updatedAt.value,
|
||||
createdBy: pickUserRelation(customer.createdByUser),
|
||||
updatedBy: pickUserRelation(customer.updatedByUser),
|
||||
};
|
||||
}
|
||||
|
||||
toDetail(customer: Customer) {
|
||||
return {
|
||||
...this.toListItem(customer),
|
||||
contacts: customer.contacts.map((contact) => this.toContactItem(contact)),
|
||||
};
|
||||
}
|
||||
|
||||
get visibleFields(): readonly string[] {
|
||||
return VISIBLE_FIELDS;
|
||||
}
|
||||
|
||||
private toContactItem(contact: CustomerContact) {
|
||||
return {
|
||||
id: contact.id,
|
||||
customerId: contact.customerId,
|
||||
name: contact.name,
|
||||
jobTitle: contact.jobTitle,
|
||||
phone: contact.phone?.value ?? null,
|
||||
mobilePhone: contact.mobilePhone?.value ?? null,
|
||||
notes: contact.notes,
|
||||
};
|
||||
}
|
||||
|
||||
private toCreateInput(input: {
|
||||
code: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
nfcId?: string | null;
|
||||
status?: string;
|
||||
contacts?: ContactBody[];
|
||||
userId: string;
|
||||
}): CreateCustomerInput {
|
||||
return {
|
||||
code: this.assertCode(input.code),
|
||||
name: this.assertName(input.name),
|
||||
phone: this.assertPhone(input.phone),
|
||||
address: this.assertAddress(input.address),
|
||||
latitude: this.assertLatitude(input.latitude ?? null),
|
||||
longitude: this.assertLongitude(input.longitude ?? null),
|
||||
nfcId: this.assertNfcId(input.nfcId ?? null),
|
||||
contacts: (input.contacts ?? []).map((contact) =>
|
||||
this.assertContact(contact),
|
||||
),
|
||||
status: input.status
|
||||
? Status.create(input.status)
|
||||
: Status.create(Status.DEFAULT),
|
||||
userId: input.userId,
|
||||
};
|
||||
}
|
||||
|
||||
private assertContact(raw: ContactBody): CustomerContactInput {
|
||||
return {
|
||||
name: this.assertContactName(raw.name),
|
||||
jobTitle: this.assertOptionalJobTitle(raw.jobTitle ?? null),
|
||||
phone: this.assertOptionalPhone(raw.phone ?? null),
|
||||
mobilePhone: this.assertOptionalPhone(raw.mobilePhone ?? null),
|
||||
notes: this.assertOptionalNotes(raw.notes ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
private assertName(raw: string): string {
|
||||
const name = raw.trim();
|
||||
if (!isValidCustomerName(name)) {
|
||||
throw new BadRequestException('Invalid customer name');
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
private assertCode(raw: string): string {
|
||||
const code = raw.trim();
|
||||
if (!isValidCustomerCode(code)) {
|
||||
throw new BadRequestException('Invalid customer code');
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
private assertAddress(raw: string): string {
|
||||
const address = raw.trim();
|
||||
if (!isValidCustomerAddress(address)) {
|
||||
throw new BadRequestException('Invalid customer address');
|
||||
}
|
||||
return address;
|
||||
}
|
||||
|
||||
private assertPhone(raw: string): PhoneNumber {
|
||||
try {
|
||||
return PhoneNumber.create(raw);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidPhoneNumberError) {
|
||||
throw new BadRequestException('Invalid phone number');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private assertOptionalPhone(raw: string | null): PhoneNumber | null {
|
||||
if (raw === null || raw.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
return this.assertPhone(raw);
|
||||
}
|
||||
|
||||
private assertLatitude(raw: number | null): number | null {
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
if (!isValidLatitude(raw)) {
|
||||
throw new BadRequestException('Invalid latitude');
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
private assertLongitude(raw: number | null): number | null {
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
if (!isValidLongitude(raw)) {
|
||||
throw new BadRequestException('Invalid longitude');
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
private assertNfcId(raw: string | null): string | null {
|
||||
if (raw === null || raw.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
const value = raw.trim();
|
||||
if (!isValidNfcId(value)) {
|
||||
throw new BadRequestException('Invalid NFC ID');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private assertContactName(raw: string): string {
|
||||
const name = raw.trim();
|
||||
if (!isValidContactName(name)) {
|
||||
throw new BadRequestException('Invalid contact name');
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
private assertOptionalJobTitle(raw: string | null): string | null {
|
||||
if (raw === null || raw.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
const value = raw.trim();
|
||||
if (!isValidContactJobTitle(value)) {
|
||||
throw new BadRequestException('Invalid contact job title');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private assertOptionalNotes(raw: string | null): string | null {
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
if (!isValidContactNotes(raw)) {
|
||||
throw new BadRequestException('Invalid contact notes');
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
PaginationQueryDto,
|
||||
UserRelationDto,
|
||||
} from '../../../../common/http/response';
|
||||
import { CORE_STATUSES } from '../../../../common/value-objects/status/status';
|
||||
import {
|
||||
CONTACT_JOB_TITLE_MAX_LENGTH,
|
||||
CONTACT_NAME_MAX_LENGTH,
|
||||
CONTACT_NOTES_MAX_LENGTH,
|
||||
CUSTOMER_ADDRESS_MAX_LENGTH,
|
||||
CUSTOMER_CODE_MAX_LENGTH,
|
||||
CUSTOMER_CODE_PATTERN,
|
||||
CUSTOMER_NAME_MAX_LENGTH,
|
||||
CUSTOMER_NAME_PATTERN,
|
||||
CUSTOMER_NFC_ID_MAX_LENGTH,
|
||||
} from '../customer-fields';
|
||||
|
||||
export class CreateCustomerContactDto {
|
||||
@ApiProperty({ example: 'Jean Luc', maxLength: CONTACT_NAME_MAX_LENGTH })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(CONTACT_NAME_MAX_LENGTH)
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Purchasing Manager' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(CONTACT_JOB_TITLE_MAX_LENGTH)
|
||||
jobTitle?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '+6281234567890' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
phone?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '+6281234567891' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
mobilePhone?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Call after 9am' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(CONTACT_NOTES_MAX_LENGTH)
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class UpdateCustomerContactDto {
|
||||
@ApiPropertyOptional({ example: 'Jean Luc' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(CONTACT_NAME_MAX_LENGTH)
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Purchasing Manager', nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(CONTACT_JOB_TITLE_MAX_LENGTH)
|
||||
jobTitle?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ example: '+6281234567890', nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ example: '+6281234567891', nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mobilePhone?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Call after 9am', nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(CONTACT_NOTES_MAX_LENGTH)
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export class CustomerContactDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
customerId!: string;
|
||||
|
||||
@ApiProperty()
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
jobTitle!: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
phone!: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
mobilePhone!: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
notes!: string | null;
|
||||
}
|
||||
|
||||
export class CreateCustomerDto {
|
||||
@ApiProperty({ example: 'CUST_01', maxLength: CUSTOMER_CODE_MAX_LENGTH })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(CUSTOMER_CODE_MAX_LENGTH)
|
||||
@Matches(CUSTOMER_CODE_PATTERN, {
|
||||
message: 'code must contain only letters, numbers, and underscores',
|
||||
})
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ example: 'Acme Corp', maxLength: CUSTOMER_NAME_MAX_LENGTH })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(CUSTOMER_NAME_MAX_LENGTH)
|
||||
@Matches(CUSTOMER_NAME_PATTERN, {
|
||||
message: 'name must contain only letters and spaces',
|
||||
})
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: '+6281234567890' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
phone!: string;
|
||||
|
||||
@ApiProperty({ example: 'Jl Sudirman No 1' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(CUSTOMER_ADDRESS_MAX_LENGTH)
|
||||
address!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: -6.2 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
latitude?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 106.8 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
longitude?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 'NFC-001' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(CUSTOMER_NFC_ID_MAX_LENGTH)
|
||||
nfcId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: CORE_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...CORE_STATUSES])
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [CreateCustomerContactDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateCustomerContactDto)
|
||||
contacts?: CreateCustomerContactDto[];
|
||||
}
|
||||
|
||||
export class UpdateCustomerDto {
|
||||
@ApiPropertyOptional({ example: 'CUST_01' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(CUSTOMER_CODE_MAX_LENGTH)
|
||||
@Matches(CUSTOMER_CODE_PATTERN, {
|
||||
message: 'code must contain only letters, numbers, and underscores',
|
||||
})
|
||||
code?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Acme Corp' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(CUSTOMER_NAME_MAX_LENGTH)
|
||||
@Matches(CUSTOMER_NAME_PATTERN, {
|
||||
message: 'name must contain only letters and spaces',
|
||||
})
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '+6281234567890' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
phone?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Jl Sudirman No 1' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(CUSTOMER_ADDRESS_MAX_LENGTH)
|
||||
address?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: -6.2, nullable: true })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
latitude?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ example: 106.8, nullable: true })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
longitude?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ example: 'NFC-001', nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(CUSTOMER_NFC_ID_MAX_LENGTH)
|
||||
nfcId?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ type: [CreateCustomerContactDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateCustomerContactDto)
|
||||
contacts?: CreateCustomerContactDto[];
|
||||
}
|
||||
|
||||
export class UpdateCustomerStatusDto {
|
||||
@ApiProperty({ enum: CORE_STATUSES })
|
||||
@IsIn([...CORE_STATUSES])
|
||||
status!: string;
|
||||
}
|
||||
|
||||
export class BulkIdsDto {
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
ids!: string[];
|
||||
}
|
||||
|
||||
export class BulkStatusDto {
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
ids!: string[];
|
||||
|
||||
@ApiProperty({ enum: CORE_STATUSES })
|
||||
@IsIn([...CORE_STATUSES])
|
||||
status!: string;
|
||||
}
|
||||
|
||||
export class ListCustomersQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
address?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nfcId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: CORE_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...CORE_STATUSES])
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Case-insensitive match on code, name, or address',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export class CustomerDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
code!: string;
|
||||
|
||||
@ApiProperty()
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: '+6281234567890' })
|
||||
phone!: string;
|
||||
|
||||
@ApiProperty()
|
||||
address!: string;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
latitude!: number | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
longitude!: number | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
nfcId!: string | null;
|
||||
|
||||
@ApiPropertyOptional({ type: [CustomerContactDto] })
|
||||
contacts?: CustomerContactDto[];
|
||||
|
||||
@ApiProperty({ enum: CORE_STATUSES })
|
||||
status!: string;
|
||||
|
||||
@ApiProperty({ description: 'Unix ms' })
|
||||
createdAt!: number;
|
||||
|
||||
@ApiProperty({ description: 'Unix ms' })
|
||||
updatedAt!: number;
|
||||
|
||||
@ApiProperty({ type: UserRelationDto })
|
||||
createdBy!: UserRelationDto;
|
||||
|
||||
@ApiProperty({ type: UserRelationDto })
|
||||
updatedBy!: UserRelationDto;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { UserRelation } from '../../../common/http/response';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
|
||||
@@ -10,6 +11,8 @@ export type Division = {
|
||||
readonly updatedAt: DateTime;
|
||||
readonly createdBy: string;
|
||||
readonly updatedBy: string;
|
||||
readonly createdByUser: UserRelation;
|
||||
readonly updatedByUser: UserRelation;
|
||||
};
|
||||
|
||||
export type CreateDivisionInput = {
|
||||
@@ -30,6 +33,8 @@ export type ListDivisionsFilters = {
|
||||
readonly code?: string;
|
||||
readonly status?: string;
|
||||
readonly search?: string;
|
||||
readonly orderBy?: string;
|
||||
readonly orderType?: string;
|
||||
readonly limit: number;
|
||||
readonly offset: number;
|
||||
};
|
||||
|
||||
@@ -43,7 +43,13 @@ describe('DivisionsRepository', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
where.mockImplementation(() => ({ limit, orderBy }));
|
||||
where.mockImplementation(() =>
|
||||
Object.assign(Promise.resolve([{ id: 'user-1', username: 'admin' }]), {
|
||||
limit,
|
||||
orderBy,
|
||||
returning,
|
||||
}),
|
||||
);
|
||||
orderBy.mockImplementation(() => ({ limit }));
|
||||
limit.mockImplementation(() => ({ offset }));
|
||||
offset.mockResolvedValue([row]);
|
||||
@@ -59,7 +65,6 @@ describe('DivisionsRepository', () => {
|
||||
update.mockReturnValue({ set });
|
||||
del.mockReturnValue({ where });
|
||||
returning.mockResolvedValue([row]);
|
||||
where.mockImplementation(() => ({ limit, orderBy, returning }));
|
||||
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [DivisionsRepository, { provide: DRIZZLE, useValue: db }],
|
||||
|
||||
@@ -4,10 +4,12 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
|
||||
import { and, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
|
||||
import { toOrderClauses } from '../../../common/http/response';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||
import { attachAuditUsers } from '../../../database/load-user-refs';
|
||||
import { divisions, type DivisionRow } from '../../../database/schema';
|
||||
import type {
|
||||
CreateDivisionInput,
|
||||
@@ -16,6 +18,15 @@ import type {
|
||||
UpdateDivisionInput,
|
||||
} from './division';
|
||||
|
||||
const DIVISION_ORDER_COLUMNS = {
|
||||
id: divisions.id,
|
||||
name: divisions.name,
|
||||
code: divisions.code,
|
||||
status: divisions.status,
|
||||
createdAt: divisions.createdAt,
|
||||
updatedAt: divisions.updatedAt,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class DivisionsRepository {
|
||||
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||
@@ -34,12 +45,16 @@ export class DivisionsRepository {
|
||||
qb = this.extendListQuery(qb, filters);
|
||||
const rows = await qb
|
||||
.where(where)
|
||||
.orderBy(asc(divisions.code))
|
||||
.orderBy(
|
||||
...toOrderClauses(DIVISION_ORDER_COLUMNS, filters, [
|
||||
{ column: 'code', type: 'ASC' },
|
||||
]),
|
||||
)
|
||||
.limit(filters.limit)
|
||||
.offset(filters.offset);
|
||||
|
||||
return {
|
||||
data: rows.map((row) => this.toDomain(row)),
|
||||
data: await this.hydrate(rows),
|
||||
total: Number(totalRow?.total ?? 0),
|
||||
};
|
||||
}
|
||||
@@ -59,7 +74,7 @@ export class DivisionsRepository {
|
||||
.where(eq(divisions.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row ? this.toDomain(row) : null;
|
||||
return row ? this.hydrateOne(row) : null;
|
||||
}
|
||||
|
||||
async findByCode(code: string): Promise<Division | null> {
|
||||
@@ -69,7 +84,7 @@ export class DivisionsRepository {
|
||||
.where(eq(divisions.code, code))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row ? this.toDomain(row) : null;
|
||||
return row ? this.hydrateOne(row) : null;
|
||||
}
|
||||
|
||||
async create(input: CreateDivisionInput): Promise<Division> {
|
||||
@@ -89,7 +104,7 @@ export class DivisionsRepository {
|
||||
})
|
||||
.returning();
|
||||
const row = inserted[0];
|
||||
return this.toDomain(row);
|
||||
return this.hydrateOne(row);
|
||||
} catch (error) {
|
||||
this.rethrowUniqueViolation(error);
|
||||
}
|
||||
@@ -139,7 +154,7 @@ export class DivisionsRepository {
|
||||
.where(eq(divisions.id, id))
|
||||
.returning();
|
||||
const row = updated[0];
|
||||
return this.toDomain(row);
|
||||
return this.hydrateOne(row);
|
||||
} catch (error) {
|
||||
this.rethrowUniqueViolation(error);
|
||||
}
|
||||
@@ -164,7 +179,7 @@ export class DivisionsRepository {
|
||||
if (!row) {
|
||||
throw new NotFoundException('Division not found');
|
||||
}
|
||||
return this.toDomain(row);
|
||||
return this.hydrateOne(row);
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
@@ -243,17 +258,25 @@ export class DivisionsRepository {
|
||||
return parts.length === 1 ? parts[0] : and(...parts);
|
||||
}
|
||||
|
||||
private toDomain(row: DivisionRow): Division {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
code: row.code,
|
||||
status: Status.create(row.status),
|
||||
createdAt: DateTime.fromUnixMs(row.createdAt),
|
||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy,
|
||||
};
|
||||
private async hydrate(rows: DivisionRow[]): Promise<Division[]> {
|
||||
return attachAuditUsers(
|
||||
this.db,
|
||||
rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
code: row.code,
|
||||
status: Status.create(row.status),
|
||||
createdAt: DateTime.fromUnixMs(row.createdAt),
|
||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
private async hydrateOne(row: DivisionRow): Promise<Division> {
|
||||
const [item] = await this.hydrate([row]);
|
||||
return item;
|
||||
}
|
||||
|
||||
private rethrowUniqueViolation(error: unknown): never {
|
||||
|
||||
@@ -33,6 +33,8 @@ describe('DivisionsService', () => {
|
||||
updatedAt: now,
|
||||
createdBy: 'user-1',
|
||||
updatedBy: 'user-1',
|
||||
createdByUser: { id: 'user-1', username: 'admin' },
|
||||
updatedByUser: { id: 'user-1', username: 'admin' },
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -68,6 +70,7 @@ describe('DivisionsService', () => {
|
||||
code: 'FIN',
|
||||
status: 'draft',
|
||||
createdAt: now.value,
|
||||
createdBy: { id: 'user-1', username: 'admin' },
|
||||
});
|
||||
expect(service.visibleFields).toContain('status');
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { PaginationResponse } from '../../../common/http/response';
|
||||
import { toListPage } from '../../../common/http/response';
|
||||
import { pickUserRelation, toListPage } from '../../../common/http/response';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import type {
|
||||
CreateDivisionInput,
|
||||
@@ -19,6 +19,8 @@ export type ListDivisionsQuery = {
|
||||
readonly code?: string;
|
||||
readonly status?: string;
|
||||
readonly search?: string;
|
||||
readonly orderBy?: string;
|
||||
readonly orderType?: string;
|
||||
readonly page?: number;
|
||||
readonly limit?: number;
|
||||
readonly offset?: number;
|
||||
@@ -48,6 +50,8 @@ export class DivisionsService {
|
||||
code: query.code,
|
||||
status: query.status,
|
||||
search: query.search,
|
||||
orderBy: query.orderBy,
|
||||
orderType: query.orderType,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
});
|
||||
@@ -221,8 +225,8 @@ export class DivisionsService {
|
||||
status: division.status.value,
|
||||
createdAt: division.createdAt.value,
|
||||
updatedAt: division.updatedAt.value,
|
||||
createdBy: division.createdBy,
|
||||
updatedBy: division.updatedBy,
|
||||
createdBy: pickUserRelation(division.createdByUser),
|
||||
updatedBy: pickUserRelation(division.updatedByUser),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../../common/http/response';
|
||||
import {
|
||||
PaginationQueryDto,
|
||||
UserRelationDto,
|
||||
} from '../../../../common/http/response';
|
||||
import { CORE_STATUSES } from '../../../../common/value-objects/status/status';
|
||||
import {
|
||||
DIVISION_CODE_MAX_LENGTH,
|
||||
@@ -19,6 +22,15 @@ import {
|
||||
DIVISION_NAME_PATTERN,
|
||||
} from '../division-fields';
|
||||
|
||||
export const DIVISION_ORDER_FIELDS = [
|
||||
'id',
|
||||
'name',
|
||||
'code',
|
||||
'status',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
] as const;
|
||||
|
||||
export class CreateDivisionDto {
|
||||
@ApiProperty({
|
||||
example: 'Human Resources',
|
||||
@@ -138,9 +150,9 @@ export class DivisionDto {
|
||||
@ApiProperty({ description: 'Unix ms' })
|
||||
updatedAt!: number;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
createdBy!: string;
|
||||
@ApiProperty({ type: UserRelationDto })
|
||||
createdBy!: UserRelationDto;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
updatedBy!: string;
|
||||
@ApiProperty({ type: UserRelationDto })
|
||||
updatedBy!: UserRelationDto;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { ListEmployeesQueryDto } from './employee.dto';
|
||||
|
||||
describe('ListEmployeesQueryDto', () => {
|
||||
it('coerces a single position query value into an array', async () => {
|
||||
const dto = plainToInstance(ListEmployeesQueryDto, { position: 'sales' });
|
||||
expect(dto.position).toEqual(['sales']);
|
||||
expect(await validate(dto)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('accepts multiple positions', async () => {
|
||||
const dto = plainToInstance(ListEmployeesQueryDto, {
|
||||
position: ['sales', 'driver'],
|
||||
});
|
||||
expect(dto.position).toEqual(['sales', 'driver']);
|
||||
expect(await validate(dto)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects an invalid position in the array', async () => {
|
||||
const dto = plainToInstance(ListEmployeesQueryDto, {
|
||||
position: ['pilot'],
|
||||
});
|
||||
expect(await validate(dto)).not.toHaveLength(0);
|
||||
});
|
||||
|
||||
it('drops empty entries and deduplicates positions', async () => {
|
||||
const dto = plainToInstance(ListEmployeesQueryDto, {
|
||||
position: ['sales', '', 'sales'],
|
||||
});
|
||||
expect(dto.position).toEqual(['sales']);
|
||||
expect(await validate(dto)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
PaginationQueryDto,
|
||||
UserRelationDto,
|
||||
} from '../../../../common/http/response';
|
||||
import { CORE_STATUSES } from '../../../../common/value-objects/status/status';
|
||||
import {
|
||||
PASSWORD_MAX_LENGTH,
|
||||
PASSWORD_MIN_LENGTH,
|
||||
USERNAME_MAX_LENGTH,
|
||||
USERNAME_MIN_LENGTH,
|
||||
USERNAME_PATTERN,
|
||||
} from '../../../users/user-fields';
|
||||
import {
|
||||
EMPLOYEE_CODE_MAX_LENGTH,
|
||||
EMPLOYEE_CODE_PATTERN,
|
||||
EMPLOYEE_NAME_MAX_LENGTH,
|
||||
EMPLOYEE_NAME_PATTERN,
|
||||
EMPLOYEE_POSITIONS,
|
||||
} from '../employee-fields';
|
||||
|
||||
export class EmployeeUserInputDto {
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
id?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'alice' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(USERNAME_MIN_LENGTH)
|
||||
@MaxLength(USERNAME_MAX_LENGTH)
|
||||
@Matches(USERNAME_PATTERN, {
|
||||
message: 'username must contain only letters, numbers, and underscores',
|
||||
})
|
||||
username?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'password123',
|
||||
format: 'password',
|
||||
writeOnly: true,
|
||||
description:
|
||||
'Required when creating a new user. Ignored/rejected when linking an existing user.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(PASSWORD_MIN_LENGTH)
|
||||
@MaxLength(PASSWORD_MAX_LENGTH)
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export class CreateEmployeeDto {
|
||||
@ApiProperty({ example: 'EMP_01', maxLength: EMPLOYEE_CODE_MAX_LENGTH })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(EMPLOYEE_CODE_MAX_LENGTH)
|
||||
@Matches(EMPLOYEE_CODE_PATTERN, {
|
||||
message: 'code must contain only letters, numbers, and underscores',
|
||||
})
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'Ada Lovelace',
|
||||
maxLength: EMPLOYEE_NAME_MAX_LENGTH,
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(EMPLOYEE_NAME_MAX_LENGTH)
|
||||
@Matches(EMPLOYEE_NAME_PATTERN, {
|
||||
message: 'name must contain only letters and spaces',
|
||||
})
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: '+6281234567890' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
phone!: string;
|
||||
|
||||
@ApiProperty({ enum: EMPLOYEE_POSITIONS, example: 'sales' })
|
||||
@IsIn([...EMPLOYEE_POSITIONS])
|
||||
position!: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: CORE_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...CORE_STATUSES])
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
userId?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: EmployeeUserInputDto })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => EmployeeUserInputDto)
|
||||
user?: EmployeeUserInputDto;
|
||||
}
|
||||
|
||||
export class UpdateEmployeeDto {
|
||||
@ApiPropertyOptional({ example: 'EMP_01' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(EMPLOYEE_CODE_MAX_LENGTH)
|
||||
@Matches(EMPLOYEE_CODE_PATTERN, {
|
||||
message: 'code must contain only letters, numbers, and underscores',
|
||||
})
|
||||
code?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Ada Lovelace' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(EMPLOYEE_NAME_MAX_LENGTH)
|
||||
@Matches(EMPLOYEE_NAME_PATTERN, {
|
||||
message: 'name must contain only letters and spaces',
|
||||
})
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '+6281234567890' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
phone?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: EMPLOYEE_POSITIONS })
|
||||
@IsOptional()
|
||||
@IsIn([...EMPLOYEE_POSITIONS])
|
||||
position?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', nullable: true })
|
||||
@ValidateIf((_, value) => value !== undefined)
|
||||
@IsUUID('4')
|
||||
userId?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ type: EmployeeUserInputDto, nullable: true })
|
||||
@ValidateIf((_, value) => value !== undefined && value !== null)
|
||||
@ValidateNested()
|
||||
@Type(() => EmployeeUserInputDto)
|
||||
user?: EmployeeUserInputDto | null;
|
||||
}
|
||||
|
||||
export class UpdateEmployeeStatusDto {
|
||||
@ApiProperty({ enum: CORE_STATUSES })
|
||||
@IsIn([...CORE_STATUSES])
|
||||
status!: string;
|
||||
}
|
||||
|
||||
export class BulkIdsDto {
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
ids!: string[];
|
||||
}
|
||||
|
||||
export class BulkStatusDto {
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
ids!: string[];
|
||||
|
||||
@ApiProperty({ enum: CORE_STATUSES })
|
||||
@IsIn([...CORE_STATUSES])
|
||||
status!: string;
|
||||
}
|
||||
|
||||
export class ListEmployeesQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: EMPLOYEE_POSITIONS,
|
||||
isArray: true,
|
||||
description: 'Filter by one or more positions',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }: { value: unknown }): string[] | undefined => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
const raw = Array.isArray(value) ? value : [value];
|
||||
const items = [
|
||||
...new Set(
|
||||
raw.filter(
|
||||
(item): item is string => typeof item === 'string' && item !== '',
|
||||
),
|
||||
),
|
||||
];
|
||||
return items.length > 0 ? items : undefined;
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMaxSize(EMPLOYEE_POSITIONS.length)
|
||||
@IsIn([...EMPLOYEE_POSITIONS], { each: true })
|
||||
position?: string[];
|
||||
|
||||
@ApiPropertyOptional({ enum: CORE_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...CORE_STATUSES])
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
userId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Case-insensitive match on code or name',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export class EmployeeDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
code!: string;
|
||||
|
||||
@ApiProperty()
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: '+6281234567890' })
|
||||
phone!: string;
|
||||
|
||||
@ApiProperty({ enum: EMPLOYEE_POSITIONS })
|
||||
position!: string;
|
||||
|
||||
@ApiProperty({ enum: CORE_STATUSES })
|
||||
status!: string;
|
||||
|
||||
@ApiProperty({ description: 'Unix ms' })
|
||||
createdAt!: number;
|
||||
|
||||
@ApiProperty({ description: 'Unix ms' })
|
||||
updatedAt!: number;
|
||||
|
||||
@ApiProperty({ type: UserRelationDto })
|
||||
createdBy!: UserRelationDto;
|
||||
|
||||
@ApiProperty({ type: UserRelationDto })
|
||||
updatedBy!: UserRelationDto;
|
||||
|
||||
@ApiPropertyOptional({ type: UserRelationDto, nullable: true })
|
||||
user!: UserRelationDto | null;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
EMPLOYEE_CODE_MAX_LENGTH,
|
||||
EMPLOYEE_NAME_MAX_LENGTH,
|
||||
isAllowedCsvUpload,
|
||||
isValidEmployeeCode,
|
||||
isValidEmployeeName,
|
||||
isValidEmployeePosition,
|
||||
parseCsvRecord,
|
||||
} from './employee-fields';
|
||||
|
||||
describe('employee fields', () => {
|
||||
describe('isValidEmployeeName', () => {
|
||||
it.each(['Ada', 'Jean Luc', 'A', 'North West Crew'])(
|
||||
'accepts %s',
|
||||
(name) => {
|
||||
expect(isValidEmployeeName(name)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['', 'Ada1', 'Jean-Luc', 'EMP_01', ' Ada', 'Ada ', 'Jean Luc'])(
|
||||
'rejects %s',
|
||||
(name) => {
|
||||
expect(isValidEmployeeName(name)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects names longer than 64 characters', () => {
|
||||
expect(
|
||||
isValidEmployeeName('A'.repeat(EMPLOYEE_NAME_MAX_LENGTH + 1)),
|
||||
).toBe(false);
|
||||
expect(isValidEmployeeName('A'.repeat(EMPLOYEE_NAME_MAX_LENGTH))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidEmployeeCode', () => {
|
||||
it.each(['EMP', 'EMP_01', 'A', 'ops2', 'A_b_1'])('accepts %s', (code) => {
|
||||
expect(isValidEmployeeCode(code)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['', 'EMP 01', 'EMP-01', 'EMP.01', ' EMP', 'EMP '])(
|
||||
'rejects %s',
|
||||
(code) => {
|
||||
expect(isValidEmployeeCode(code)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects codes longer than 16 characters', () => {
|
||||
expect(
|
||||
isValidEmployeeCode('A'.repeat(EMPLOYEE_CODE_MAX_LENGTH + 1)),
|
||||
).toBe(false);
|
||||
expect(isValidEmployeeCode('A'.repeat(EMPLOYEE_CODE_MAX_LENGTH))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidEmployeePosition', () => {
|
||||
it.each(['sales', 'driver', 'crew'])('accepts %s', (position) => {
|
||||
expect(isValidEmployeePosition(position)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['', 'Sales', 'pilot', 'crew '])('rejects %s', (position) => {
|
||||
expect(isValidEmployeePosition(position)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCsvRecord', () => {
|
||||
it('keeps commas inside quoted fields', () => {
|
||||
expect(parseCsvRecord('EMP_01,Ada Lovelace,"sales, lead"')).toEqual([
|
||||
'EMP_01',
|
||||
'Ada Lovelace',
|
||||
'sales, lead',
|
||||
]);
|
||||
});
|
||||
|
||||
it('unescapes doubled quotes', () => {
|
||||
expect(parseCsvRecord('"Say ""hello""",x')).toEqual(['Say "hello"', 'x']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAllowedCsvUpload', () => {
|
||||
it('accepts csv mime or .csv names', () => {
|
||||
expect(
|
||||
isAllowedCsvUpload({
|
||||
mimetype: 'text/csv',
|
||||
originalname: 'x.txt',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isAllowedCsvUpload({
|
||||
mimetype: 'application/octet-stream',
|
||||
originalname: 'employees.csv',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-csv files', () => {
|
||||
expect(
|
||||
isAllowedCsvUpload({
|
||||
mimetype: 'application/pdf',
|
||||
originalname: 'x.pdf',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
export const EMPLOYEE_NAME_MAX_LENGTH = 64;
|
||||
export const EMPLOYEE_CODE_MAX_LENGTH = 16;
|
||||
|
||||
/** Letters with single spaces between words. */
|
||||
export const EMPLOYEE_NAME_PATTERN = /^[A-Za-z]+(?: [A-Za-z]+)*$/;
|
||||
|
||||
/** Alphanumeric and underscore; no spaces. */
|
||||
export const EMPLOYEE_CODE_PATTERN = /^[A-Za-z0-9_]+$/;
|
||||
|
||||
export const EMPLOYEE_POSITIONS = ['sales', 'driver', 'crew'] as const;
|
||||
|
||||
export type EmployeePosition = (typeof EMPLOYEE_POSITIONS)[number];
|
||||
|
||||
export function isValidEmployeeName(raw: string): boolean {
|
||||
return (
|
||||
typeof raw === 'string' &&
|
||||
raw.length > 0 &&
|
||||
raw.length <= EMPLOYEE_NAME_MAX_LENGTH &&
|
||||
EMPLOYEE_NAME_PATTERN.test(raw)
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidEmployeeCode(raw: string): boolean {
|
||||
return (
|
||||
typeof raw === 'string' &&
|
||||
raw.length > 0 &&
|
||||
raw.length <= EMPLOYEE_CODE_MAX_LENGTH &&
|
||||
EMPLOYEE_CODE_PATTERN.test(raw)
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidEmployeePosition(raw: string): raw is EmployeePosition {
|
||||
return (EMPLOYEE_POSITIONS as readonly string[]).includes(raw);
|
||||
}
|
||||
|
||||
/** RFC 4180-style record split that preserves commas inside quotes. */
|
||||
export function parseCsvRecord(line: string): string[] {
|
||||
const cells: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (inQuotes) {
|
||||
if (ch === '"') {
|
||||
if (line[i + 1] === '"') {
|
||||
current += '"';
|
||||
i += 1;
|
||||
} else {
|
||||
inQuotes = false;
|
||||
}
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
} else if (ch === '"') {
|
||||
inQuotes = true;
|
||||
} else if (ch === ',') {
|
||||
cells.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
cells.push(current.trim());
|
||||
return cells;
|
||||
}
|
||||
|
||||
export function isAllowedCsvUpload(file: {
|
||||
mimetype: string;
|
||||
originalname: string;
|
||||
}): boolean {
|
||||
return (
|
||||
file.mimetype.includes('csv') ||
|
||||
file.originalname.toLowerCase().endsWith('.csv')
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import type { EmployeePosition } from './employee-fields';
|
||||
|
||||
export type Employee = {
|
||||
readonly id: string;
|
||||
readonly code: string;
|
||||
readonly name: string;
|
||||
readonly phone: PhoneNumber;
|
||||
readonly position: EmployeePosition;
|
||||
readonly status: Status;
|
||||
readonly createdAt: DateTime;
|
||||
readonly updatedAt: DateTime;
|
||||
readonly createdBy: string;
|
||||
readonly updatedBy: string;
|
||||
readonly createdByUser: { readonly id: string; readonly username: string };
|
||||
readonly updatedByUser: { readonly id: string; readonly username: string };
|
||||
readonly userId: string | null;
|
||||
readonly user: { readonly id: string; readonly username: string } | null;
|
||||
};
|
||||
|
||||
export type CreateEmployeeInput = {
|
||||
readonly code: string;
|
||||
readonly name: string;
|
||||
readonly phone: PhoneNumber;
|
||||
readonly position: EmployeePosition;
|
||||
readonly status?: Status;
|
||||
readonly userId: string;
|
||||
readonly assignedUserId?: string | null;
|
||||
};
|
||||
|
||||
export type UpdateEmployeeInput = {
|
||||
readonly code?: string;
|
||||
readonly name?: string;
|
||||
readonly phone?: PhoneNumber;
|
||||
readonly position?: EmployeePosition;
|
||||
readonly userId: string;
|
||||
readonly assignedUserId?: string | null;
|
||||
};
|
||||
|
||||
export type ListEmployeesFilters = {
|
||||
readonly code?: string;
|
||||
readonly name?: string;
|
||||
readonly phone?: string;
|
||||
readonly position?: readonly string[];
|
||||
readonly status?: string;
|
||||
readonly userId?: string;
|
||||
readonly search?: string;
|
||||
readonly orderBy?: string;
|
||||
readonly orderType?: string;
|
||||
readonly limit: number;
|
||||
readonly offset: number;
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { EmployeesReadController } from './employees-read.controller';
|
||||
import { EmployeesService } from './employees.service';
|
||||
|
||||
describe('EmployeesReadController', () => {
|
||||
let controller: EmployeesReadController;
|
||||
const service = {
|
||||
list: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
controllers: [EmployeesReadController],
|
||||
providers: [{ provide: EmployeesService, useValue: service }],
|
||||
}).compile();
|
||||
controller = moduleRef.get(EmployeesReadController);
|
||||
});
|
||||
|
||||
it('list delegates to the service', async () => {
|
||||
service.list.mockResolvedValue({ data: [], total: 0 });
|
||||
await expect(controller.list({ page: 1 })).resolves.toEqual({
|
||||
data: [],
|
||||
total: 0,
|
||||
});
|
||||
expect(service.list).toHaveBeenCalledWith({ page: 1 });
|
||||
});
|
||||
|
||||
it('list forwards an array of positions', async () => {
|
||||
service.list.mockResolvedValue({ data: [], total: 0 });
|
||||
await controller.list({ position: ['sales', 'driver'] });
|
||||
expect(service.list).toHaveBeenCalledWith({
|
||||
position: ['sales', 'driver'],
|
||||
});
|
||||
});
|
||||
|
||||
it('findOne delegates to the service', async () => {
|
||||
service.findById.mockResolvedValue({ id: 'emp-1' });
|
||||
await expect(controller.findOne('emp-1')).resolves.toEqual({
|
||||
id: 'emp-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiForbiddenResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
||||
import {
|
||||
Pagination,
|
||||
type PaginationResponse,
|
||||
PaginationMetaDto,
|
||||
} from '../../../common/http/response';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import { EmployeeDto, ListEmployeesQueryDto } from './dto/employee.dto';
|
||||
import { EmployeesService } from './employees.service';
|
||||
|
||||
export const EMPLOYEE_PRIVILEGE_KEY = 'CONFIGURATION.EMPLOYEE';
|
||||
|
||||
@ApiTags('employees')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('employees')
|
||||
export class EmployeesReadController {
|
||||
constructor(private readonly employeesService: EmployeesService) {}
|
||||
|
||||
@Get()
|
||||
@Pagination()
|
||||
@RequirePrivilege(EMPLOYEE_PRIVILEGE_KEY, 'view')
|
||||
@ApiOperation({ summary: 'List employees' })
|
||||
@ApiOkResponse({
|
||||
schema: {
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/EmployeeDto' },
|
||||
},
|
||||
meta: { $ref: '#/components/schemas/PaginationMetaDto' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
list(
|
||||
@Query() query: ListEmployeesQueryDto,
|
||||
): Promise<PaginationResponse<EmployeeDto>> {
|
||||
return this.employeesService.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePrivilege(EMPLOYEE_PRIVILEGE_KEY, 'view')
|
||||
@ApiOperation({ summary: 'Get employee detail' })
|
||||
@ApiOkResponse({ type: EmployeeDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<EmployeeDto> {
|
||||
return this.employeesService.findById(id);
|
||||
}
|
||||
}
|
||||
|
||||
void PaginationMetaDto;
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { EmployeesWriteController } from './employees-write.controller';
|
||||
import { EmployeesService } from './employees.service';
|
||||
|
||||
const createDto = {
|
||||
code: 'EMP_01',
|
||||
name: 'Ada Lovelace',
|
||||
phone: '+6281234567890',
|
||||
position: 'sales',
|
||||
};
|
||||
|
||||
describe('EmployeesWriteController', () => {
|
||||
let controller: EmployeesWriteController;
|
||||
const service = {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateStatus: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
bulkDelete: jest.fn(),
|
||||
bulkUpdateStatus: jest.fn(),
|
||||
importCsv: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
controllers: [EmployeesWriteController],
|
||||
providers: [{ provide: EmployeesService, useValue: service }],
|
||||
}).compile();
|
||||
controller = moduleRef.get(EmployeesWriteController);
|
||||
});
|
||||
|
||||
it('create passes dto fields and user id', async () => {
|
||||
service.create.mockResolvedValue({ id: 'emp-1' });
|
||||
await controller.create(createDto, 'user-1');
|
||||
expect(service.create).toHaveBeenCalledWith({
|
||||
...createDto,
|
||||
status: undefined,
|
||||
userId: 'user-1',
|
||||
assignedUserId: undefined,
|
||||
user: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('update, updateStatus, and delete delegate', async () => {
|
||||
service.update.mockResolvedValue({ id: 'emp-1' });
|
||||
service.updateStatus.mockResolvedValue({ id: 'emp-1' });
|
||||
service.delete.mockResolvedValue(undefined);
|
||||
await controller.update('emp-1', { name: 'Ada Lovelace' }, 'user-1');
|
||||
await controller.updateStatus('emp-1', { status: 'active' }, 'user-1');
|
||||
await controller.delete('emp-1');
|
||||
expect(service.updateStatus).toHaveBeenCalledWith(
|
||||
'emp-1',
|
||||
'active',
|
||||
'user-1',
|
||||
);
|
||||
expect(service.delete).toHaveBeenCalledWith('emp-1');
|
||||
});
|
||||
|
||||
it('bulk and import delegate', async () => {
|
||||
service.bulkDelete.mockResolvedValue({ deleted: 1 });
|
||||
service.bulkUpdateStatus.mockResolvedValue({ updated: 1 });
|
||||
service.importCsv.mockResolvedValue({ imported: 1 });
|
||||
await controller.bulkDelete({ ids: ['emp-1'] });
|
||||
await controller.bulkStatus(
|
||||
{ ids: ['emp-1'], status: 'archived' },
|
||||
'user-1',
|
||||
);
|
||||
await controller.importCsv(
|
||||
{
|
||||
buffer: Buffer.from(
|
||||
'code,name,phone,position\nEMP_01,Ada,+6281234567890,sales',
|
||||
),
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
expect(service.importCsv).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('importCsv uses empty string when file is missing', async () => {
|
||||
service.importCsv.mockResolvedValue({ imported: 0 });
|
||||
await controller.importCsv(undefined, 'user-1');
|
||||
expect(service.importCsv).toHaveBeenCalledWith('', 'user-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiCreatedResponse,
|
||||
ApiForbiddenResponse,
|
||||
ApiNoContentResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import { isAllowedCsvUpload } from './employee-fields';
|
||||
import { EMPLOYEE_PRIVILEGE_KEY } from './employees-read.controller';
|
||||
import { EmployeesService } from './employees.service';
|
||||
import {
|
||||
BulkIdsDto,
|
||||
BulkStatusDto,
|
||||
CreateEmployeeDto,
|
||||
EmployeeDto,
|
||||
UpdateEmployeeDto,
|
||||
UpdateEmployeeStatusDto,
|
||||
} from './dto/employee.dto';
|
||||
|
||||
@ApiTags('employees')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('employees')
|
||||
export class EmployeesWriteController {
|
||||
constructor(private readonly employeesService: EmployeesService) {}
|
||||
|
||||
@Post('import')
|
||||
@RequirePrivilege(EMPLOYEE_PRIVILEGE_KEY, 'import')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: 1_048_576 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (!isAllowedCsvUpload(file)) {
|
||||
cb(new BadRequestException('Only CSV files are allowed'), false);
|
||||
return;
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
}),
|
||||
)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: { type: 'string', format: 'binary' },
|
||||
},
|
||||
required: ['file'],
|
||||
},
|
||||
})
|
||||
@ApiOperation({ summary: 'Import employees from CSV' })
|
||||
@ApiOkResponse({
|
||||
schema: { properties: { imported: { type: 'number' } } },
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
importCsv(
|
||||
@UploadedFile() file: { buffer?: Buffer } | undefined,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<{ imported: number }> {
|
||||
const csv = file?.buffer?.toString('utf8') ?? '';
|
||||
return this.employeesService.importCsv(csv, userId);
|
||||
}
|
||||
|
||||
@Post('bulk-delete')
|
||||
@HttpCode(200)
|
||||
@RequirePrivilege(EMPLOYEE_PRIVILEGE_KEY, 'delete')
|
||||
@ApiOperation({ summary: 'Bulk delete employees' })
|
||||
@ApiOkResponse({
|
||||
schema: { properties: { deleted: { type: 'number' } } },
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
bulkDelete(@Body() dto: BulkIdsDto): Promise<{ deleted: number }> {
|
||||
return this.employeesService.bulkDelete(dto.ids);
|
||||
}
|
||||
|
||||
@Post('bulk-status')
|
||||
@HttpCode(200)
|
||||
@RequirePrivilege(EMPLOYEE_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Bulk update employee status' })
|
||||
@ApiOkResponse({
|
||||
schema: { properties: { updated: { type: 'number' } } },
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
bulkStatus(
|
||||
@Body() dto: BulkStatusDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<{ updated: number }> {
|
||||
return this.employeesService.bulkUpdateStatus(dto.ids, dto.status, userId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePrivilege(EMPLOYEE_PRIVILEGE_KEY, 'create')
|
||||
@ApiOperation({ summary: 'Create employee' })
|
||||
@ApiCreatedResponse({ type: EmployeeDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
create(
|
||||
@Body() dto: CreateEmployeeDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<EmployeeDto> {
|
||||
return this.employeesService.create({
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
phone: dto.phone,
|
||||
position: dto.position,
|
||||
status: dto.status,
|
||||
userId,
|
||||
assignedUserId: dto.userId,
|
||||
user: dto.user,
|
||||
});
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@RequirePrivilege(EMPLOYEE_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Update employee status' })
|
||||
@ApiOkResponse({ type: EmployeeDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
updateStatus(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateEmployeeStatusDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<EmployeeDto> {
|
||||
return this.employeesService.updateStatus(id, dto.status, userId);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePrivilege(EMPLOYEE_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Update employee (not status)' })
|
||||
@ApiOkResponse({ type: EmployeeDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateEmployeeDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<EmployeeDto> {
|
||||
return this.employeesService.update(id, {
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
phone: dto.phone,
|
||||
position: dto.position,
|
||||
userId,
|
||||
assignedUserId: dto.userId,
|
||||
user: dto.user,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@RequirePrivilege(EMPLOYEE_PRIVILEGE_KEY, 'delete')
|
||||
@ApiOperation({ summary: 'Delete employee' })
|
||||
@ApiNoContentResponse()
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
async delete(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||
await this.employeesService.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersModule } from '../../users/users.module';
|
||||
import { EmployeesReadController } from './employees-read.controller';
|
||||
import { EmployeesWriteController } from './employees-write.controller';
|
||||
import { EmployeesRepository } from './employees.repository';
|
||||
import { EmployeesService } from './employees.service';
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule],
|
||||
controllers: [EmployeesReadController, EmployeesWriteController],
|
||||
providers: [EmployeesRepository, EmployeesService],
|
||||
exports: [EmployeesService],
|
||||
})
|
||||
export class EmployeesModule {}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { DRIZZLE } from '../../../database/database.module';
|
||||
import { EmployeesRepository } from './employees.repository';
|
||||
|
||||
describe('EmployeesRepository', () => {
|
||||
let repository: EmployeesRepository;
|
||||
|
||||
const limit = jest.fn();
|
||||
const orderBy = jest.fn();
|
||||
const offset = jest.fn();
|
||||
const where = jest.fn();
|
||||
const from = jest.fn();
|
||||
const select = jest.fn();
|
||||
const returning = jest.fn();
|
||||
const values = jest.fn();
|
||||
const insert = jest.fn();
|
||||
const set = jest.fn();
|
||||
const update = jest.fn();
|
||||
const del = jest.fn();
|
||||
const transaction = jest.fn();
|
||||
const $dynamic = jest.fn();
|
||||
|
||||
const db = {
|
||||
select,
|
||||
insert,
|
||||
update,
|
||||
delete: del,
|
||||
transaction,
|
||||
};
|
||||
|
||||
const row = {
|
||||
id: 'emp-1',
|
||||
code: 'EMP_01',
|
||||
name: 'Ada Lovelace',
|
||||
phone: '+6281234567890',
|
||||
position: 'sales',
|
||||
status: 'draft',
|
||||
createdAt: 1_700_000_000_000,
|
||||
updatedAt: 1_700_000_000_000,
|
||||
createdBy: 'user-1',
|
||||
updatedBy: 'user-1',
|
||||
userId: null,
|
||||
};
|
||||
|
||||
const createInput = {
|
||||
code: 'EMP_01',
|
||||
name: 'Ada Lovelace',
|
||||
phone: PhoneNumber.create('+6281234567890'),
|
||||
position: 'sales' as const,
|
||||
userId: 'user-1',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
where.mockImplementation(() =>
|
||||
Object.assign(Promise.resolve([{ id: 'user-1', username: 'admin' }]), {
|
||||
limit,
|
||||
orderBy,
|
||||
returning,
|
||||
}),
|
||||
);
|
||||
orderBy.mockImplementation(() => ({ limit }));
|
||||
limit.mockImplementation(() => ({ offset }));
|
||||
offset.mockResolvedValue([row]);
|
||||
from.mockImplementation(() => ({
|
||||
where,
|
||||
$dynamic,
|
||||
}));
|
||||
$dynamic.mockReturnValue({ where });
|
||||
select.mockImplementation(() => ({ from }));
|
||||
values.mockReturnValue({ returning });
|
||||
insert.mockReturnValue({ values });
|
||||
set.mockReturnValue({ where });
|
||||
update.mockReturnValue({ set });
|
||||
del.mockReturnValue({ where });
|
||||
returning.mockResolvedValue([row]);
|
||||
transaction.mockImplementation((fn: (tx: typeof db) => unknown) => fn(db));
|
||||
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [EmployeesRepository, { provide: DRIZZLE, useValue: db }],
|
||||
}).compile();
|
||||
repository = moduleRef.get(EmployeesRepository);
|
||||
});
|
||||
|
||||
it('findById maps a row to domain Employee', async () => {
|
||||
limit.mockResolvedValueOnce([row]);
|
||||
const employee = await repository.findById('emp-1');
|
||||
expect(employee).toMatchObject({
|
||||
id: 'emp-1',
|
||||
code: 'EMP_01',
|
||||
name: 'Ada Lovelace',
|
||||
position: 'sales',
|
||||
createdBy: 'user-1',
|
||||
});
|
||||
expect(employee?.phone.value).toBe('+6281234567890');
|
||||
expect(employee?.status.value).toBe('draft');
|
||||
expect(employee?.createdAt.value).toBe(1_700_000_000_000);
|
||||
});
|
||||
|
||||
it('findById returns null when missing', async () => {
|
||||
limit.mockResolvedValueOnce([]);
|
||||
await expect(repository.findById('missing')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('findByCode maps a row', async () => {
|
||||
limit.mockResolvedValueOnce([row]);
|
||||
const employee = await repository.findByCode('EMP_01');
|
||||
expect(employee?.code).toBe('EMP_01');
|
||||
});
|
||||
|
||||
it('findByUserId maps a row', async () => {
|
||||
limit.mockResolvedValueOnce([row]);
|
||||
const employee = await repository.findByUserId('user-1');
|
||||
expect(employee?.id).toBe('emp-1');
|
||||
});
|
||||
|
||||
it('list returns mapped rows and total', async () => {
|
||||
select
|
||||
.mockImplementationOnce(() => ({
|
||||
from: () => ({
|
||||
where: () => Promise.resolve([{ total: 1 }]),
|
||||
}),
|
||||
}))
|
||||
.mockImplementationOnce(() => ({
|
||||
from: () => ({
|
||||
$dynamic: () => ({
|
||||
where: () => ({
|
||||
orderBy: () => ({
|
||||
limit: () => ({
|
||||
offset: () => Promise.resolve([row]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
const result = await repository.list({
|
||||
name: 'Ada',
|
||||
code: 'EMP',
|
||||
phone: '+628',
|
||||
position: ['sales', 'driver'],
|
||||
status: 'draft',
|
||||
search: 'ada',
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
});
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.data[0].code).toBe('EMP_01');
|
||||
expect(result.data[0].phone.value).toBe('+6281234567890');
|
||||
});
|
||||
|
||||
it('create inserts and maps unique violations', async () => {
|
||||
returning.mockResolvedValueOnce([row]);
|
||||
const created = await repository.create(createInput);
|
||||
expect(created.code).toBe('EMP_01');
|
||||
|
||||
returning.mockRejectedValueOnce({ code: '23505' });
|
||||
await expect(repository.create(createInput)).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
|
||||
returning.mockRejectedValueOnce({
|
||||
cause: { code: '23505' },
|
||||
});
|
||||
await expect(repository.create(createInput)).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('create rethrows unknown errors', async () => {
|
||||
returning.mockRejectedValue(new Error('db down'));
|
||||
await expect(repository.create(createInput)).rejects.toThrow('db down');
|
||||
});
|
||||
|
||||
it('createMany returns 0 for an empty batch and inserts otherwise', async () => {
|
||||
await expect(repository.createMany([])).resolves.toBe(0);
|
||||
transaction.mockImplementation(
|
||||
async (fn: (tx: typeof db) => Promise<void>) => {
|
||||
await fn(db);
|
||||
},
|
||||
);
|
||||
await expect(repository.createMany([createInput])).resolves.toBe(1);
|
||||
});
|
||||
|
||||
it('createMany maps unique violations', async () => {
|
||||
transaction.mockRejectedValue({ code: '23505' });
|
||||
await expect(repository.createMany([createInput])).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('update throws when missing and maps unique violations', async () => {
|
||||
limit.mockResolvedValueOnce([]);
|
||||
await expect(
|
||||
repository.update('missing', { userId: 'user-1' }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
|
||||
limit.mockResolvedValueOnce([row]);
|
||||
returning.mockRejectedValueOnce({ code: '23505' });
|
||||
await expect(
|
||||
repository.update('emp-1', { code: 'EMP_02', userId: 'user-1' }),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('updateStatus throws when missing', async () => {
|
||||
returning.mockResolvedValueOnce([]);
|
||||
await expect(
|
||||
repository.updateStatus('missing', Status.create('active'), 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('delete throws when missing', async () => {
|
||||
returning.mockResolvedValueOnce([]);
|
||||
await expect(repository.delete('missing')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('bulkUpdateStatus and bulkDelete return 0 for empty ids', async () => {
|
||||
await expect(
|
||||
repository.bulkUpdateStatus([], Status.create('active'), 'user-1'),
|
||||
).resolves.toBe(0);
|
||||
await expect(repository.bulkDelete([])).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it('bulkUpdateStatus and bulkDelete return affected counts', async () => {
|
||||
returning.mockResolvedValue([{ id: 'emp-1' }, { id: 'emp-2' }]);
|
||||
await expect(
|
||||
repository.bulkUpdateStatus(
|
||||
['emp-1', 'emp-2'],
|
||||
Status.create('active'),
|
||||
'user-1',
|
||||
),
|
||||
).resolves.toBe(2);
|
||||
returning.mockResolvedValue([{ id: 'emp-1' }]);
|
||||
await expect(repository.bulkDelete(['emp-1'])).resolves.toBe(1);
|
||||
});
|
||||
|
||||
it('extendListQuery is a passthrough hook', () => {
|
||||
const qb = { join: true };
|
||||
expect(repository.extendListQuery(qb, { limit: 10, offset: 0 })).toBe(qb);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,387 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { and, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
|
||||
import { toOrderClauses } from '../../../common/http/response/order-clause';
|
||||
import { attachAuditUsers } from '../../../database/load-user-refs';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||
import { employees, type EmployeeRow } from '../../../database/employees-table';
|
||||
import { users } from '../../../database/schema';
|
||||
import type { EmployeePosition } from './employee-fields';
|
||||
import type {
|
||||
CreateEmployeeInput,
|
||||
Employee,
|
||||
ListEmployeesFilters,
|
||||
UpdateEmployeeInput,
|
||||
} from './employee';
|
||||
|
||||
const EMPLOYEE_ORDER_COLUMNS = {
|
||||
id: employees.id,
|
||||
code: employees.code,
|
||||
name: employees.name,
|
||||
phone: employees.phone,
|
||||
position: employees.position,
|
||||
status: employees.status,
|
||||
createdAt: employees.createdAt,
|
||||
updatedAt: employees.updatedAt,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class EmployeesRepository {
|
||||
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||
|
||||
async list(
|
||||
filters: ListEmployeesFilters,
|
||||
): Promise<{ data: Employee[]; total: number }> {
|
||||
const where = this.buildListWhere(filters);
|
||||
const totalRows = await this.db
|
||||
.select({ total: count() })
|
||||
.from(employees)
|
||||
.where(where);
|
||||
const totalRow = totalRows[0];
|
||||
|
||||
let qb = this.db.select().from(employees).$dynamic();
|
||||
qb = this.extendListQuery(qb, filters);
|
||||
const rows = await qb
|
||||
.where(where)
|
||||
.orderBy(
|
||||
...toOrderClauses(EMPLOYEE_ORDER_COLUMNS, filters, [
|
||||
{ column: 'code', type: 'ASC' },
|
||||
]),
|
||||
)
|
||||
.limit(filters.limit)
|
||||
.offset(filters.offset);
|
||||
|
||||
const mapped = await Promise.all(
|
||||
rows.map(async (row) =>
|
||||
this.hydrateOne(row, await this.loadAssignedUser(row.userId)),
|
||||
),
|
||||
);
|
||||
return {
|
||||
data: mapped,
|
||||
total: Number(totalRow?.total ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for modules to add joins/extra predicates without forking list.
|
||||
*/
|
||||
extendListQuery<T>(qb: T, filters: ListEmployeesFilters): T {
|
||||
void filters;
|
||||
return qb;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Employee | null> {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(employees)
|
||||
.where(eq(employees.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row
|
||||
? this.hydrateOne(row, await this.loadAssignedUser(row.userId))
|
||||
: null;
|
||||
}
|
||||
|
||||
async findByCode(code: string): Promise<Employee | null> {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(employees)
|
||||
.where(eq(employees.code, code))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row
|
||||
? this.hydrateOne(row, await this.loadAssignedUser(row.userId))
|
||||
: null;
|
||||
}
|
||||
|
||||
async findByUserId(userId: string): Promise<Employee | null> {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(employees)
|
||||
.where(eq(employees.userId, userId))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row
|
||||
? this.hydrateOne(row, await this.loadAssignedUser(row.userId))
|
||||
: null;
|
||||
}
|
||||
|
||||
async create(input: CreateEmployeeInput): Promise<Employee> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const status = input.status ?? Status.create(Status.DEFAULT);
|
||||
try {
|
||||
const inserted = await this.db
|
||||
.insert(employees)
|
||||
.values(this.toInsertValues(input, status, now, input.userId))
|
||||
.returning();
|
||||
const row = inserted[0];
|
||||
return this.hydrateOne(row, await this.loadAssignedUser(row.userId));
|
||||
} catch (error) {
|
||||
this.rethrowUniqueViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createMany(inputs: CreateEmployeeInput[]): Promise<number> {
|
||||
if (inputs.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
try {
|
||||
await this.db.transaction(async (tx) => {
|
||||
for (const input of inputs) {
|
||||
const status = input.status ?? Status.create(Status.DEFAULT);
|
||||
await tx
|
||||
.insert(employees)
|
||||
.values(this.toInsertValues(input, status, now, input.userId));
|
||||
}
|
||||
});
|
||||
return inputs.length;
|
||||
} catch (error) {
|
||||
this.rethrowUniqueViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, input: UpdateEmployeeInput): Promise<Employee> {
|
||||
const existing = await this.findById(id);
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Employee not found');
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
try {
|
||||
const updated = await this.db
|
||||
.update(employees)
|
||||
.set({
|
||||
code: input.code ?? existing.code,
|
||||
name: input.name ?? existing.name,
|
||||
phone: input.phone?.value ?? existing.phone.value,
|
||||
position: input.position ?? existing.position,
|
||||
updatedAt: now.value,
|
||||
updatedBy: input.userId,
|
||||
...(input.assignedUserId !== undefined
|
||||
? { userId: input.assignedUserId }
|
||||
: {}),
|
||||
})
|
||||
.where(eq(employees.id, id))
|
||||
.returning();
|
||||
const row = updated[0];
|
||||
return this.hydrateOne(row, await this.loadAssignedUser(row.userId));
|
||||
} catch (error) {
|
||||
this.rethrowUniqueViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
status: Status,
|
||||
userId: string,
|
||||
): Promise<Employee> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const updated = await this.db
|
||||
.update(employees)
|
||||
.set({
|
||||
status: status.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(eq(employees.id, id))
|
||||
.returning();
|
||||
const row = updated[0];
|
||||
if (!row) {
|
||||
throw new NotFoundException('Employee not found');
|
||||
}
|
||||
return this.hydrateOne(row, await this.loadAssignedUser(row.userId));
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
ids: string[],
|
||||
status: Status,
|
||||
userId: string,
|
||||
): Promise<number> {
|
||||
if (ids.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const rows = await this.db
|
||||
.update(employees)
|
||||
.set({
|
||||
status: status.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(inArray(employees.id, ids))
|
||||
.returning({ id: employees.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const deleted = await this.db
|
||||
.delete(employees)
|
||||
.where(eq(employees.id, id))
|
||||
.returning({ id: employees.id });
|
||||
if (deleted.length === 0) {
|
||||
throw new NotFoundException('Employee not found');
|
||||
}
|
||||
}
|
||||
|
||||
async bulkDelete(ids: string[]): Promise<number> {
|
||||
if (ids.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const deleted = await this.db
|
||||
.delete(employees)
|
||||
.where(inArray(employees.id, ids))
|
||||
.returning({ id: employees.id });
|
||||
return deleted.length;
|
||||
}
|
||||
|
||||
private buildListWhere(filters: ListEmployeesFilters): SQL | undefined {
|
||||
const parts: SQL[] = [];
|
||||
if (filters.code) {
|
||||
parts.push(ilike(employees.code, `%${filters.code}%`));
|
||||
}
|
||||
if (filters.name) {
|
||||
parts.push(ilike(employees.name, `%${filters.name}%`));
|
||||
}
|
||||
if (filters.phone) {
|
||||
parts.push(ilike(employees.phone, `%${filters.phone}%`));
|
||||
}
|
||||
if (filters.position && filters.position.length > 0) {
|
||||
parts.push(inArray(employees.position, [...filters.position]));
|
||||
}
|
||||
if (filters.status) {
|
||||
parts.push(eq(employees.status, filters.status));
|
||||
}
|
||||
if (filters.userId) {
|
||||
parts.push(eq(employees.userId, filters.userId));
|
||||
}
|
||||
if (filters.search) {
|
||||
const search = or(
|
||||
ilike(employees.code, `%${filters.search}%`),
|
||||
ilike(employees.name, `%${filters.search}%`),
|
||||
);
|
||||
if (search) {
|
||||
parts.push(search);
|
||||
}
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return parts.length === 1 ? parts[0] : and(...parts);
|
||||
}
|
||||
|
||||
private toInsertValues(
|
||||
input: CreateEmployeeInput,
|
||||
status: Status,
|
||||
now: DateTime,
|
||||
userId: string,
|
||||
) {
|
||||
return {
|
||||
code: input.code,
|
||||
name: input.name,
|
||||
phone: input.phone.value,
|
||||
position: input.position,
|
||||
status: status.value,
|
||||
createdAt: now.value,
|
||||
updatedAt: now.value,
|
||||
createdBy: userId,
|
||||
updatedBy: userId,
|
||||
userId: input.assignedUserId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private selectWithUser() {
|
||||
return this.db
|
||||
.select({
|
||||
employee: employees,
|
||||
user: {
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
},
|
||||
})
|
||||
.from(employees)
|
||||
.leftJoin(users, eq(employees.userId, users.id));
|
||||
}
|
||||
|
||||
private async loadAssignedUser(
|
||||
userId: string | null,
|
||||
): Promise<{ id: string; username: string } | null> {
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
const rows = await this.db
|
||||
.select({ id: users.id, username: users.username })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
private toDomain(
|
||||
row: EmployeeRow,
|
||||
user: { id: string; username: string } | null,
|
||||
) {
|
||||
return {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
phone: PhoneNumber.create(row.phone),
|
||||
position: row.position as EmployeePosition,
|
||||
status: Status.create(row.status),
|
||||
createdAt: DateTime.fromUnixMs(row.createdAt),
|
||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy,
|
||||
userId: row.userId ?? null,
|
||||
user: user?.id ? { id: user.id, username: user.username } : null,
|
||||
};
|
||||
}
|
||||
|
||||
private async hydrateOne(
|
||||
row: EmployeeRow,
|
||||
user: { id: string; username: string } | null,
|
||||
): Promise<Employee> {
|
||||
const [item] = await attachAuditUsers(this.db, [this.toDomain(row, user)]);
|
||||
return item;
|
||||
}
|
||||
|
||||
private rethrowUniqueViolation(error: unknown): never {
|
||||
const err = this.unwrapDbError(error);
|
||||
if (err.code === '23505') {
|
||||
const constraint = err.constraint ?? '';
|
||||
if (constraint.includes('user_id')) {
|
||||
throw new ConflictException('User is already assigned to an employee');
|
||||
}
|
||||
throw new ConflictException('Employee code already exists');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
private unwrapDbError(error: unknown): {
|
||||
code?: string;
|
||||
constraint?: string;
|
||||
} {
|
||||
let current: unknown = error;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (!current || typeof current !== 'object') {
|
||||
break;
|
||||
}
|
||||
const obj = current as {
|
||||
code?: string;
|
||||
constraint?: string;
|
||||
cause?: unknown;
|
||||
};
|
||||
if (obj.code === '23505' || obj.code === '23503') {
|
||||
return { code: obj.code, constraint: obj.constraint };
|
||||
}
|
||||
current = obj.cause;
|
||||
}
|
||||
return error as { code?: string; constraint?: string };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import type { Employee } from './employee';
|
||||
import { USERS_WRITER, type UsersWriter } from '../../users/users-writer';
|
||||
import { EmployeesRepository } from './employees.repository';
|
||||
import { EmployeesService } from './employees.service';
|
||||
|
||||
describe('EmployeesService', () => {
|
||||
let service: EmployeesService;
|
||||
let repository: jest.Mocked<
|
||||
Pick<
|
||||
EmployeesRepository,
|
||||
| 'list'
|
||||
| 'findById'
|
||||
| 'findByUserId'
|
||||
| 'create'
|
||||
| 'createMany'
|
||||
| 'update'
|
||||
| 'updateStatus'
|
||||
| 'bulkUpdateStatus'
|
||||
| 'delete'
|
||||
| 'bulkDelete'
|
||||
>
|
||||
>;
|
||||
let usersService: {
|
||||
findById: jest.MockedFunction<UsersWriter['findById']>;
|
||||
createManaged: jest.MockedFunction<UsersWriter['createManaged']>;
|
||||
update: jest.MockedFunction<UsersWriter['update']>;
|
||||
};
|
||||
|
||||
const now = DateTime.fromUnixMs(1_700_000_000_000);
|
||||
const sample: Employee = {
|
||||
id: 'emp-1',
|
||||
code: 'EMP_01',
|
||||
name: 'Ada Lovelace',
|
||||
phone: PhoneNumber.create('+6281234567890'),
|
||||
position: 'sales',
|
||||
status: Status.create('draft'),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: 'user-1',
|
||||
updatedBy: 'user-1',
|
||||
createdByUser: { id: 'user-1', username: 'admin' },
|
||||
updatedByUser: { id: 'user-1', username: 'admin' },
|
||||
userId: null,
|
||||
user: null,
|
||||
};
|
||||
|
||||
const createInput = {
|
||||
code: 'EMP_01',
|
||||
name: 'Ada Lovelace',
|
||||
phone: '+6281234567890',
|
||||
position: 'sales',
|
||||
userId: 'user-1',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
repository = {
|
||||
list: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
findByUserId: jest.fn(),
|
||||
create: jest.fn(),
|
||||
createMany: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateStatus: jest.fn(),
|
||||
bulkUpdateStatus: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
bulkDelete: jest.fn(),
|
||||
};
|
||||
usersService = {
|
||||
findById: jest.fn().mockResolvedValue({ id: 'user-2' }),
|
||||
createManaged: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
EmployeesService,
|
||||
{ provide: EmployeesRepository, useValue: repository },
|
||||
{ provide: USERS_WRITER, useValue: usersService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = moduleRef.get(EmployeesService);
|
||||
});
|
||||
|
||||
it('list maps visible fields including phone and position', async () => {
|
||||
repository.list.mockResolvedValue({ data: [sample], total: 1 });
|
||||
const result = await service.list({ page: 1, limit: 10 });
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.data[0]).toMatchObject({
|
||||
id: 'emp-1',
|
||||
code: 'EMP_01',
|
||||
name: 'Ada Lovelace',
|
||||
phone: '+6281234567890',
|
||||
position: 'sales',
|
||||
status: 'draft',
|
||||
createdAt: now.value,
|
||||
});
|
||||
expect(service.visibleFields).toEqual(
|
||||
expect.arrayContaining(['phone', 'position', 'status']),
|
||||
);
|
||||
});
|
||||
|
||||
it('list forwards an array of positions to the repository', async () => {
|
||||
repository.list.mockResolvedValue({ data: [], total: 0 });
|
||||
await service.list({
|
||||
position: ['sales', 'driver'],
|
||||
page: 1,
|
||||
limit: 10,
|
||||
});
|
||||
expect(repository.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ position: ['sales', 'driver'] }),
|
||||
);
|
||||
});
|
||||
|
||||
it('findById throws when missing', async () => {
|
||||
repository.findById.mockResolvedValue(null);
|
||||
await expect(service.findById('missing')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('findById returns mapped item', async () => {
|
||||
repository.findById.mockResolvedValue(sample);
|
||||
const result = await service.findById('emp-1');
|
||||
expect(result.id).toBe('emp-1');
|
||||
expect(result.phone).toBe('+6281234567890');
|
||||
expect(result.position).toBe('sales');
|
||||
});
|
||||
|
||||
it('create defaults status to draft and maps phone', async () => {
|
||||
repository.create.mockResolvedValue(sample);
|
||||
await service.create(createInput);
|
||||
const arg = repository.create.mock.calls[0][0];
|
||||
expect(arg.status?.value).toBe('draft');
|
||||
expect(arg.phone.value).toBe('+6281234567890');
|
||||
expect(arg.position).toBe('sales');
|
||||
});
|
||||
|
||||
it('create uses provided status', async () => {
|
||||
repository.create.mockResolvedValue(sample);
|
||||
await service.create({ ...createInput, status: 'active' });
|
||||
const arg = repository.create.mock.calls[0][0];
|
||||
expect(arg.status?.value).toBe('active');
|
||||
});
|
||||
|
||||
it('create rejects invalid phone without echoing input', async () => {
|
||||
await expect(
|
||||
service.create({ ...createInput, phone: '081234567890' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('create rejects invalid name, code, or position', async () => {
|
||||
await expect(
|
||||
service.create({ ...createInput, name: 'Ada1' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(
|
||||
service.create({ ...createInput, code: 'EMP 01' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(
|
||||
service.create({ ...createInput, position: 'pilot' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update rejects status field', async () => {
|
||||
await expect(
|
||||
service.update('emp-1', { status: 'active', userId: 'user-1' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('update trims and validates fields', async () => {
|
||||
repository.update.mockResolvedValue(sample);
|
||||
await service.update('emp-1', {
|
||||
name: 'Jean Luc',
|
||||
code: 'EMP_02',
|
||||
phone: '+6281234567891',
|
||||
position: 'driver',
|
||||
userId: 'user-1',
|
||||
});
|
||||
expect(repository.update).toHaveBeenCalledWith(
|
||||
'emp-1',
|
||||
expect.objectContaining({
|
||||
name: 'Jean Luc',
|
||||
code: 'EMP_02',
|
||||
position: 'driver',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('update rejects invalid name, code, phone, or position', async () => {
|
||||
await expect(
|
||||
service.update('emp-1', { name: 'Ops1', userId: 'user-1' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(
|
||||
service.update('emp-1', { code: 'OPS 1', userId: 'user-1' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(
|
||||
service.update('emp-1', { phone: '0812', userId: 'user-1' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(
|
||||
service.update('emp-1', { position: 'pilot', userId: 'user-1' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('updateStatus updates via repository', async () => {
|
||||
repository.updateStatus.mockResolvedValue(sample);
|
||||
await service.updateStatus('emp-1', 'active', 'user-1');
|
||||
expect(repository.updateStatus).toHaveBeenCalledWith(
|
||||
'emp-1',
|
||||
expect.objectContaining({ value: 'active' }),
|
||||
'user-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('delete, bulkDelete, and bulkUpdateStatus delegate', async () => {
|
||||
repository.delete.mockResolvedValue(undefined);
|
||||
repository.bulkDelete.mockResolvedValue(2);
|
||||
repository.bulkUpdateStatus.mockResolvedValue(2);
|
||||
await service.delete('emp-1');
|
||||
await expect(service.bulkDelete(['a', 'b'])).resolves.toEqual({
|
||||
deleted: 2,
|
||||
});
|
||||
await expect(
|
||||
service.bulkUpdateStatus(['a', 'b'], 'archived', 'user-1'),
|
||||
).resolves.toEqual({ updated: 2 });
|
||||
});
|
||||
|
||||
it('importCsv imports valid rows', async () => {
|
||||
repository.createMany.mockResolvedValue(1);
|
||||
const result = await service.importCsv(
|
||||
'code,name,phone,position,status\nEMP_01,Ada Lovelace,+6281234567890,sales,draft',
|
||||
'user-1',
|
||||
);
|
||||
expect(result.imported).toBe(1);
|
||||
expect(repository.createMany).toHaveBeenCalledTimes(1);
|
||||
const row = repository.createMany.mock.calls[0][0][0];
|
||||
expect(row.phone.value).toBe('+6281234567890');
|
||||
expect(row.position).toBe('sales');
|
||||
});
|
||||
|
||||
it('importCsv fails the batch on invalid phone', async () => {
|
||||
await expect(
|
||||
service.importCsv(
|
||||
'code,name,phone,position\nEMP_01,Ada Lovelace,081234,sales',
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repository.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('importCsv fails batch on invalid name, code, or position', async () => {
|
||||
await expect(
|
||||
service.importCsv(
|
||||
'code,name,phone,position\nEMP_01,Ada1,+6281234567890,sales',
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(
|
||||
service.importCsv(
|
||||
'code,name,phone,position\nEMP 01,Ada Lovelace,+6281234567890,sales',
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(
|
||||
service.importCsv(
|
||||
'code,name,phone,position\nEMP_01,Ada Lovelace,+6281234567890,pilot',
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('importCsv rejects empty, oversized, and headerless files', async () => {
|
||||
await expect(service.importCsv('', 'user-1')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(
|
||||
service.importCsv('code,name\nEMP_01,Ada', 'user-1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
const huge = [
|
||||
'code,name,phone,position',
|
||||
...Array.from(
|
||||
{ length: 501 },
|
||||
(_, i) => `E${i},Ada Lovelace,+6281234567890,sales`,
|
||||
),
|
||||
].join('\n');
|
||||
await expect(service.importCsv(huge, 'user-1')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('importCsv rejects missing required cells', async () => {
|
||||
await expect(
|
||||
service.importCsv(
|
||||
'code,name,phone,position\n,Ada Lovelace,+6281234567890,sales',
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('importCsv rejects invalid status without echoing it', async () => {
|
||||
await expect(
|
||||
service.importCsv(
|
||||
'code,name,phone,position,status\nEMP_01,Ada Lovelace,+6281234567890,sales,nope',
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repository.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update with only userId still calls repository', async () => {
|
||||
repository.update.mockResolvedValue(sample);
|
||||
await service.update('emp-1', { userId: 'user-1' });
|
||||
expect(repository.update).toHaveBeenCalledWith(
|
||||
'emp-1',
|
||||
expect.objectContaining({ userId: 'user-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('create creates a user when nested user has no id', async () => {
|
||||
usersService.createManaged.mockResolvedValue({ id: 'user-9' });
|
||||
repository.create.mockResolvedValue({
|
||||
...sample,
|
||||
userId: 'user-9',
|
||||
user: { id: 'user-9', username: 'bob' },
|
||||
});
|
||||
|
||||
const result = await service.create({
|
||||
...createInput,
|
||||
user: { username: 'bob', password: 'password123' },
|
||||
});
|
||||
|
||||
expect(usersService.createManaged).toHaveBeenCalledWith({
|
||||
username: 'bob',
|
||||
password: 'password123',
|
||||
actorUserId: 'user-1',
|
||||
});
|
||||
expect(repository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ assignedUserId: 'user-9' }),
|
||||
);
|
||||
expect(result.user).toEqual({ id: 'user-9', username: 'bob' });
|
||||
});
|
||||
|
||||
it('create rejects password when linking an existing user', async () => {
|
||||
await expect(
|
||||
service.create({
|
||||
...createInput,
|
||||
user: { id: 'user-2', password: 'password123' },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(usersService.update).not.toHaveBeenCalled();
|
||||
expect(repository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('create rejects nested user without username and password', async () => {
|
||||
await expect(
|
||||
service.create({
|
||||
...createInput,
|
||||
user: { username: 'bob' },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('create links and updates an existing user by nested id', async () => {
|
||||
usersService.update.mockResolvedValue({ id: 'user-2' });
|
||||
repository.create.mockResolvedValue({
|
||||
...sample,
|
||||
userId: 'user-2',
|
||||
user: { id: 'user-2', username: 'bobby' },
|
||||
});
|
||||
|
||||
await service.create({
|
||||
...createInput,
|
||||
user: { id: 'user-2', username: 'bobby' },
|
||||
});
|
||||
|
||||
expect(usersService.createManaged).not.toHaveBeenCalled();
|
||||
expect(usersService.update).toHaveBeenCalledWith(
|
||||
'user-2',
|
||||
expect.objectContaining({
|
||||
username: 'bobby',
|
||||
actorUserId: 'user-1',
|
||||
}),
|
||||
);
|
||||
expect(repository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ assignedUserId: 'user-2' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('create rejects userId that differs from nested user.id', async () => {
|
||||
await expect(
|
||||
service.create({
|
||||
...createInput,
|
||||
assignedUserId: 'user-2',
|
||||
user: { id: 'user-3', username: 'bob', password: 'password123' },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('update writes nested user onto the currently linked account', async () => {
|
||||
repository.findById.mockResolvedValue({
|
||||
...sample,
|
||||
userId: 'user-2',
|
||||
user: { id: 'user-2', username: 'alice' },
|
||||
});
|
||||
usersService.update.mockResolvedValue({ id: 'user-2' });
|
||||
repository.update.mockResolvedValue({
|
||||
...sample,
|
||||
userId: 'user-2',
|
||||
user: { id: 'user-2', username: 'alice2' },
|
||||
});
|
||||
|
||||
const result = await service.update('emp-1', {
|
||||
userId: 'user-1',
|
||||
user: { username: 'alice2' },
|
||||
});
|
||||
|
||||
expect(usersService.update).toHaveBeenCalledWith(
|
||||
'user-2',
|
||||
expect.objectContaining({
|
||||
username: 'alice2',
|
||||
actorUserId: 'user-1',
|
||||
}),
|
||||
);
|
||||
expect(result.user?.username).toBe('alice2');
|
||||
});
|
||||
|
||||
it('update with user null unlinks the assigned user', async () => {
|
||||
repository.update.mockResolvedValue({
|
||||
...sample,
|
||||
userId: null,
|
||||
user: null,
|
||||
});
|
||||
const result = await service.update('emp-1', {
|
||||
userId: 'user-1',
|
||||
user: null,
|
||||
});
|
||||
expect(repository.update).toHaveBeenCalledWith(
|
||||
'emp-1',
|
||||
expect.objectContaining({ assignedUserId: null }),
|
||||
);
|
||||
expect(result.user).toBeNull();
|
||||
});
|
||||
|
||||
it('update rejects nested user assigned to another employee', async () => {
|
||||
repository.findById.mockResolvedValue(sample);
|
||||
usersService.findById.mockResolvedValue({ id: 'user-2' });
|
||||
repository.findByUserId.mockResolvedValue({
|
||||
...sample,
|
||||
id: 'emp-other',
|
||||
userId: 'user-2',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.update('emp-1', {
|
||||
userId: 'user-1',
|
||||
user: { id: 'user-2' },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,438 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { PaginationResponse } from '../../../common/http/response';
|
||||
import {
|
||||
pickRelation,
|
||||
pickUserRelation,
|
||||
USER_RELATION_FIELDS,
|
||||
toListPage,
|
||||
} from '../../../common/http/response';
|
||||
import { InvalidPhoneNumberError } from '../../../common/value-objects/phone-number/invalid-phone-number.error';
|
||||
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import type {
|
||||
CreateEmployeeInput,
|
||||
Employee,
|
||||
UpdateEmployeeInput,
|
||||
} from './employee';
|
||||
import {
|
||||
isValidEmployeeCode,
|
||||
isValidEmployeeName,
|
||||
isValidEmployeePosition,
|
||||
parseCsvRecord,
|
||||
type EmployeePosition,
|
||||
} from './employee-fields';
|
||||
import { USERS_WRITER, type UsersWriter } from '../../users/users-writer';
|
||||
import { EmployeesRepository } from './employees.repository';
|
||||
|
||||
export type ListEmployeesQuery = {
|
||||
readonly code?: string;
|
||||
readonly name?: string;
|
||||
readonly phone?: string;
|
||||
readonly position?: readonly string[];
|
||||
readonly status?: string;
|
||||
readonly userId?: string;
|
||||
readonly search?: string;
|
||||
readonly orderBy?: string;
|
||||
readonly orderType?: string;
|
||||
readonly page?: number;
|
||||
readonly limit?: number;
|
||||
readonly offset?: number;
|
||||
};
|
||||
|
||||
const VISIBLE_FIELDS = [
|
||||
'id',
|
||||
'code',
|
||||
'name',
|
||||
'phone',
|
||||
'position',
|
||||
'status',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'createdBy',
|
||||
'updatedBy',
|
||||
'user',
|
||||
] as const;
|
||||
|
||||
const CSV_REQUIRED_HEADERS = ['code', 'name', 'phone', 'position'] as const;
|
||||
|
||||
interface EmployeeUserWrite {
|
||||
readonly id?: string;
|
||||
readonly username?: string;
|
||||
readonly password?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EmployeesService {
|
||||
constructor(
|
||||
private readonly employeesRepository: EmployeesRepository,
|
||||
@Inject(USERS_WRITER) private readonly usersService: UsersWriter,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
query: ListEmployeesQuery,
|
||||
): Promise<PaginationResponse<ReturnType<EmployeesService['toListItem']>>> {
|
||||
const page = toListPage(query);
|
||||
const { data, total } = await this.employeesRepository.list({
|
||||
code: query.code,
|
||||
name: query.name,
|
||||
phone: query.phone,
|
||||
position: query.position,
|
||||
status: query.status,
|
||||
userId: query.userId,
|
||||
search: query.search,
|
||||
orderBy: query.orderBy,
|
||||
orderType: query.orderType,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
});
|
||||
return {
|
||||
data: data.map((item) => this.toListItem(item)),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async findById(
|
||||
id: string,
|
||||
): Promise<ReturnType<EmployeesService['toListItem']>> {
|
||||
const employee = await this.employeesRepository.findById(id);
|
||||
if (!employee) {
|
||||
throw new NotFoundException('Employee not found');
|
||||
}
|
||||
return this.toListItem(employee);
|
||||
}
|
||||
|
||||
async findByCode(
|
||||
code: string,
|
||||
): Promise<ReturnType<EmployeesService['toListItem']>> {
|
||||
const employee = await this.employeesRepository.findByCode(code);
|
||||
if (!employee) {
|
||||
throw new NotFoundException('Employee not found');
|
||||
}
|
||||
return this.toListItem(employee);
|
||||
}
|
||||
|
||||
async create(input: {
|
||||
code: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
position: string;
|
||||
status?: string;
|
||||
userId: string;
|
||||
assignedUserId?: string | null;
|
||||
user?: EmployeeUserWrite;
|
||||
}): Promise<ReturnType<EmployeesService['toListItem']>> {
|
||||
this.assertCode(input.code);
|
||||
this.assertName(input.name);
|
||||
this.assertPhone(input.phone);
|
||||
this.assertPosition(input.position);
|
||||
const assignedUserId = await this.resolveAssignedUser({
|
||||
actorUserId: input.userId,
|
||||
assignedUserId: input.assignedUserId,
|
||||
user: input.user,
|
||||
currentUserId: null,
|
||||
currentEmployeeId: null,
|
||||
});
|
||||
const created = await this.employeesRepository.create(
|
||||
await this.toCreateInput({ ...input, assignedUserId }),
|
||||
);
|
||||
return this.toListItem(created);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: {
|
||||
code?: string;
|
||||
name?: string;
|
||||
phone?: string;
|
||||
position?: string;
|
||||
status?: unknown;
|
||||
userId: string;
|
||||
assignedUserId?: string | null;
|
||||
user?: EmployeeUserWrite | null;
|
||||
},
|
||||
): Promise<ReturnType<EmployeesService['toListItem']>> {
|
||||
if (input.status !== undefined) {
|
||||
throw new BadRequestException('status cannot be updated via PATCH');
|
||||
}
|
||||
let assignedUserId = await this.assertAssignedUserId(input.assignedUserId);
|
||||
if (input.user !== undefined) {
|
||||
if (input.user === null) {
|
||||
assignedUserId = null;
|
||||
} else {
|
||||
const current = await this.employeesRepository.findById(id);
|
||||
if (!current) {
|
||||
throw new NotFoundException('Employee not found');
|
||||
}
|
||||
assignedUserId = await this.resolveAssignedUser({
|
||||
actorUserId: input.userId,
|
||||
assignedUserId: input.assignedUserId,
|
||||
user: input.user,
|
||||
currentUserId: current.userId,
|
||||
currentEmployeeId: current.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
const payload: UpdateEmployeeInput = {
|
||||
code: input.code !== undefined ? this.assertCode(input.code) : undefined,
|
||||
name: input.name !== undefined ? this.assertName(input.name) : undefined,
|
||||
phone:
|
||||
input.phone !== undefined ? this.assertPhone(input.phone) : undefined,
|
||||
position:
|
||||
input.position !== undefined
|
||||
? this.assertPosition(input.position)
|
||||
: undefined,
|
||||
userId: input.userId,
|
||||
assignedUserId,
|
||||
};
|
||||
const updated = await this.employeesRepository.update(id, payload);
|
||||
return this.toListItem(updated);
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
statusRaw: string,
|
||||
userId: string,
|
||||
): Promise<ReturnType<EmployeesService['toListItem']>> {
|
||||
const status = Status.create(statusRaw);
|
||||
const updated = await this.employeesRepository.updateStatus(
|
||||
id,
|
||||
status,
|
||||
userId,
|
||||
);
|
||||
return this.toListItem(updated);
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
ids: string[],
|
||||
statusRaw: string,
|
||||
userId: string,
|
||||
): Promise<{ updated: number }> {
|
||||
const status = Status.create(statusRaw);
|
||||
const updated = await this.employeesRepository.bulkUpdateStatus(
|
||||
ids,
|
||||
status,
|
||||
userId,
|
||||
);
|
||||
return { updated };
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.employeesRepository.delete(id);
|
||||
}
|
||||
|
||||
async bulkDelete(ids: string[]): Promise<{ deleted: number }> {
|
||||
const deleted = await this.employeesRepository.bulkDelete(ids);
|
||||
return { deleted };
|
||||
}
|
||||
|
||||
async importCsv(csv: string, userId: string): Promise<{ imported: number }> {
|
||||
const rawLines = csv.split(/\r?\n/);
|
||||
const filled = rawLines
|
||||
.map((line, index) => ({ line: line.trim(), lineNo: index + 1 }))
|
||||
.filter((entry) => entry.line.length > 0);
|
||||
if (filled.length === 0) {
|
||||
throw new BadRequestException('CSV is empty');
|
||||
}
|
||||
if (filled.length > 501) {
|
||||
throw new BadRequestException('CSV exceeds maximum of 500 data rows');
|
||||
}
|
||||
|
||||
const header = parseCsvRecord(filled[0].line).map((h) =>
|
||||
h.trim().toLowerCase(),
|
||||
);
|
||||
const missing = CSV_REQUIRED_HEADERS.filter((h) => header.indexOf(h) < 0);
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException('CSV must include required headers');
|
||||
}
|
||||
|
||||
const idx = (key: string) => header.indexOf(key);
|
||||
const errors: string[] = [];
|
||||
const rows: CreateEmployeeInput[] = [];
|
||||
for (let i = 1; i < filled.length; i++) {
|
||||
const cols = parseCsvRecord(filled[i].line);
|
||||
const rowNum = filled[i].lineNo;
|
||||
try {
|
||||
const statusRaw = idx('status') >= 0 ? cols[idx('status')] : '';
|
||||
const assignedRaw = idx('userid') >= 0 ? cols[idx('userid')] : '';
|
||||
rows.push(
|
||||
await this.toCreateInput({
|
||||
code: cols[idx('code')] ?? '',
|
||||
name: cols[idx('name')] ?? '',
|
||||
phone: cols[idx('phone')] ?? '',
|
||||
position: cols[idx('position')] ?? '',
|
||||
status: statusRaw || undefined,
|
||||
userId,
|
||||
assignedUserId: assignedRaw || undefined,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
const reason =
|
||||
error instanceof BadRequestException ? error.message : 'invalid data';
|
||||
errors.push(`row ${rowNum}: ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new BadRequestException({
|
||||
message: 'CSV validation failed',
|
||||
errors,
|
||||
});
|
||||
}
|
||||
|
||||
await this.employeesRepository.createMany(rows);
|
||||
return { imported: rows.length };
|
||||
}
|
||||
|
||||
toListItem(employee: Employee) {
|
||||
return {
|
||||
id: employee.id,
|
||||
code: employee.code,
|
||||
name: employee.name,
|
||||
phone: employee.phone.value,
|
||||
position: employee.position,
|
||||
status: employee.status.value,
|
||||
createdAt: employee.createdAt.value,
|
||||
updatedAt: employee.updatedAt.value,
|
||||
createdBy: pickUserRelation(employee.createdByUser),
|
||||
updatedBy: pickUserRelation(employee.updatedByUser),
|
||||
user: pickRelation(employee.user, USER_RELATION_FIELDS),
|
||||
};
|
||||
}
|
||||
|
||||
get visibleFields(): readonly string[] {
|
||||
return VISIBLE_FIELDS;
|
||||
}
|
||||
|
||||
private async toCreateInput(input: {
|
||||
code: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
position: string;
|
||||
status?: string;
|
||||
userId: string;
|
||||
assignedUserId?: string | null;
|
||||
}): Promise<CreateEmployeeInput> {
|
||||
return {
|
||||
code: this.assertCode(input.code),
|
||||
name: this.assertName(input.name),
|
||||
phone: this.assertPhone(input.phone),
|
||||
position: this.assertPosition(input.position),
|
||||
status: input.status
|
||||
? Status.create(input.status)
|
||||
: Status.create(Status.DEFAULT),
|
||||
userId: input.userId,
|
||||
assignedUserId: await this.assertAssignedUserId(input.assignedUserId),
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveAssignedUser(input: {
|
||||
actorUserId: string;
|
||||
assignedUserId?: string | null;
|
||||
user?: EmployeeUserWrite;
|
||||
currentUserId: string | null;
|
||||
currentEmployeeId: string | null;
|
||||
}): Promise<string | null | undefined> {
|
||||
if (!input.user) {
|
||||
return this.assertAssignedUserId(input.assignedUserId);
|
||||
}
|
||||
if (
|
||||
input.user.id &&
|
||||
input.assignedUserId &&
|
||||
input.user.id !== input.assignedUserId
|
||||
) {
|
||||
throw new BadRequestException('userId and user.id must match');
|
||||
}
|
||||
|
||||
const targetId =
|
||||
input.user.id ?? input.assignedUserId ?? input.currentUserId ?? undefined;
|
||||
|
||||
if (targetId) {
|
||||
if (input.user.password !== undefined) {
|
||||
throw new BadRequestException(
|
||||
'password can only be set when creating a user',
|
||||
);
|
||||
}
|
||||
await this.assertAssignedUserId(targetId);
|
||||
const taken = await this.employeesRepository.findByUserId(targetId);
|
||||
if (taken && taken.id !== input.currentEmployeeId) {
|
||||
throw new ConflictException('User is already assigned to an employee');
|
||||
}
|
||||
if (input.user.username !== undefined) {
|
||||
await this.usersService.update(targetId, {
|
||||
username: input.user.username,
|
||||
actorUserId: input.actorUserId,
|
||||
});
|
||||
}
|
||||
return targetId;
|
||||
}
|
||||
|
||||
if (!input.user.username || !input.user.password) {
|
||||
throw new BadRequestException(
|
||||
'username and password are required to create a user',
|
||||
);
|
||||
}
|
||||
const created = await this.usersService.createManaged({
|
||||
username: input.user.username,
|
||||
password: input.user.password,
|
||||
actorUserId: input.actorUserId,
|
||||
});
|
||||
return created.id;
|
||||
}
|
||||
|
||||
private async assertAssignedUserId(
|
||||
userId?: string | null,
|
||||
): Promise<string | null | undefined> {
|
||||
if (userId === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (userId === null || userId === '') {
|
||||
return null;
|
||||
}
|
||||
const user = await this.usersService.findById(userId);
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
private assertName(raw: string): string {
|
||||
const name = raw.trim();
|
||||
if (!isValidEmployeeName(name)) {
|
||||
throw new BadRequestException('Invalid employee name');
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
private assertCode(raw: string): string {
|
||||
const code = raw.trim();
|
||||
if (!isValidEmployeeCode(code)) {
|
||||
throw new BadRequestException('Invalid employee code');
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
private assertPosition(raw: string): EmployeePosition {
|
||||
const position = raw.trim();
|
||||
if (!isValidEmployeePosition(position)) {
|
||||
throw new BadRequestException('Invalid employee position');
|
||||
}
|
||||
return position;
|
||||
}
|
||||
|
||||
private assertPhone(raw: string): PhoneNumber {
|
||||
try {
|
||||
return PhoneNumber.create(raw);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidPhoneNumberError) {
|
||||
throw new BadRequestException('Invalid phone number');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
PaginationQueryDto,
|
||||
UserRelationDto,
|
||||
} from '../../../../common/http/response';
|
||||
import { CORE_STATUSES } from '../../../../common/value-objects/status/status';
|
||||
import {
|
||||
PRODUCT_BRAND_MAX_LENGTH,
|
||||
PRODUCT_CODE_MAX_LENGTH,
|
||||
PRODUCT_CODE_PATTERN,
|
||||
PRODUCT_NAME_MAX_LENGTH,
|
||||
PRODUCT_NAME_PATTERN,
|
||||
PRODUCT_UNIT_MAX_LENGTH,
|
||||
PRODUCT_UNIT_PATTERN,
|
||||
} from '../product-fields';
|
||||
|
||||
export class CreateProductDto {
|
||||
@ApiProperty({ example: 'FUEL_95', maxLength: PRODUCT_CODE_MAX_LENGTH })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(PRODUCT_CODE_MAX_LENGTH)
|
||||
@Matches(PRODUCT_CODE_PATTERN, {
|
||||
message: 'code must contain only letters, numbers, and underscores',
|
||||
})
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'Fuel 95',
|
||||
maxLength: PRODUCT_NAME_MAX_LENGTH,
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(PRODUCT_NAME_MAX_LENGTH)
|
||||
@Matches(PRODUCT_NAME_PATTERN, {
|
||||
message: 'name must contain only letters, digits, and common punctuation',
|
||||
})
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'L', maxLength: PRODUCT_UNIT_MAX_LENGTH })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(PRODUCT_UNIT_MAX_LENGTH)
|
||||
@Matches(PRODUCT_UNIT_PATTERN, {
|
||||
message: 'unit must contain only letters and numbers',
|
||||
})
|
||||
unit?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '12500.0000' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
price?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'Pertamina',
|
||||
maxLength: PRODUCT_BRAND_MAX_LENGTH,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(PRODUCT_BRAND_MAX_LENGTH)
|
||||
brand?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: CORE_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...CORE_STATUSES])
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export class UpdateProductDto {
|
||||
@ApiPropertyOptional({ example: 'FUEL_95' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(PRODUCT_CODE_MAX_LENGTH)
|
||||
@Matches(PRODUCT_CODE_PATTERN, {
|
||||
message: 'code must contain only letters, numbers, and underscores',
|
||||
})
|
||||
code?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Fuel 95' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(PRODUCT_NAME_MAX_LENGTH)
|
||||
@Matches(PRODUCT_NAME_PATTERN, {
|
||||
message: 'name must contain only letters, digits, and common punctuation',
|
||||
})
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'L', nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(PRODUCT_UNIT_MAX_LENGTH)
|
||||
unit?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ example: '12500.0000', nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
price?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Pertamina', nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(PRODUCT_BRAND_MAX_LENGTH)
|
||||
brand?: string | null;
|
||||
}
|
||||
|
||||
export class UpdateProductStatusDto {
|
||||
@ApiProperty({ enum: CORE_STATUSES })
|
||||
@IsIn([...CORE_STATUSES])
|
||||
status!: string;
|
||||
}
|
||||
|
||||
export class BulkIdsDto {
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
ids!: string[];
|
||||
}
|
||||
|
||||
export class BulkStatusDto {
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
ids!: string[];
|
||||
|
||||
@ApiProperty({ enum: CORE_STATUSES })
|
||||
@IsIn([...CORE_STATUSES])
|
||||
status!: string;
|
||||
}
|
||||
|
||||
export class ListProductsQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
unit?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
brand?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: CORE_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...CORE_STATUSES])
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Case-insensitive match on code or name',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export class ProductDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
code!: string;
|
||||
|
||||
@ApiProperty()
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
unit!: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true, example: '12500.0000' })
|
||||
price!: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
brand!: string | null;
|
||||
|
||||
@ApiProperty({ enum: CORE_STATUSES })
|
||||
status!: string;
|
||||
|
||||
@ApiProperty({ description: 'Unix ms' })
|
||||
createdAt!: number;
|
||||
|
||||
@ApiProperty({ description: 'Unix ms' })
|
||||
updatedAt!: number;
|
||||
|
||||
@ApiProperty({ type: UserRelationDto })
|
||||
createdBy!: UserRelationDto;
|
||||
|
||||
@ApiProperty({ type: UserRelationDto })
|
||||
updatedBy!: UserRelationDto;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
PRODUCT_CODE_MAX_LENGTH,
|
||||
PRODUCT_NAME_MAX_LENGTH,
|
||||
isAllowedCsvUpload,
|
||||
isValidProductBrand,
|
||||
isValidProductCode,
|
||||
isValidProductName,
|
||||
isValidProductUnit,
|
||||
parseCsvRecord,
|
||||
} from './product-fields';
|
||||
|
||||
describe('product fields', () => {
|
||||
describe('isValidProductName', () => {
|
||||
it.each(['Fuel', 'Fuel 95', 'Oil (SAE 40)', 'A'])('accepts %s', (name) => {
|
||||
expect(isValidProductName(name)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['', ' Fuel', 'Fuel ', 'Fuel 95'])('rejects %s', (name) => {
|
||||
expect(isValidProductName(name)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects names longer than the max length', () => {
|
||||
expect(isValidProductName('A'.repeat(PRODUCT_NAME_MAX_LENGTH + 1))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidProductCode', () => {
|
||||
it.each(['FUEL_95', 'A', 'p1'])('accepts %s', (code) => {
|
||||
expect(isValidProductCode(code)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['', 'FUEL 95', 'FUEL-95'])('rejects %s', (code) => {
|
||||
expect(isValidProductCode(code)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects codes longer than the max length', () => {
|
||||
expect(isValidProductCode('A'.repeat(PRODUCT_CODE_MAX_LENGTH + 1))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidProductUnit and brand', () => {
|
||||
it('accepts unit and brand values', () => {
|
||||
expect(isValidProductUnit('L')).toBe(true);
|
||||
expect(isValidProductBrand('Pertamina')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty unit or brand', () => {
|
||||
expect(isValidProductUnit('')).toBe(false);
|
||||
expect(isValidProductBrand('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCsvRecord', () => {
|
||||
it('keeps commas inside quoted fields', () => {
|
||||
expect(parseCsvRecord('FUEL_95,"Fuel, 95",L')).toEqual([
|
||||
'FUEL_95',
|
||||
'Fuel, 95',
|
||||
'L',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAllowedCsvUpload', () => {
|
||||
it('accepts csv mime or .csv names', () => {
|
||||
expect(
|
||||
isAllowedCsvUpload({
|
||||
mimetype: 'text/csv',
|
||||
originalname: 'x.txt',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isAllowedCsvUpload({
|
||||
mimetype: 'application/octet-stream',
|
||||
originalname: 'products.csv',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-csv files', () => {
|
||||
expect(
|
||||
isAllowedCsvUpload({
|
||||
mimetype: 'application/pdf',
|
||||
originalname: 'x.pdf',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
export const PRODUCT_NAME_MAX_LENGTH = 128;
|
||||
export const PRODUCT_CODE_MAX_LENGTH = 32;
|
||||
export const PRODUCT_UNIT_MAX_LENGTH = 16;
|
||||
export const PRODUCT_BRAND_MAX_LENGTH = 64;
|
||||
|
||||
/** Alphanumeric and underscore; no spaces. */
|
||||
export const PRODUCT_CODE_PATTERN = /^[A-Za-z0-9_]+$/;
|
||||
|
||||
/** Letters, digits, and common punctuation with single spaces between tokens. */
|
||||
export const PRODUCT_NAME_PATTERN =
|
||||
/^[A-Za-z0-9][A-Za-z0-9+\-./()]*?(?: [A-Za-z0-9+\-./()]+)*$/;
|
||||
|
||||
export const PRODUCT_UNIT_PATTERN = /^[A-Za-z0-9]+$/;
|
||||
|
||||
export function isValidProductName(raw: string): boolean {
|
||||
return (
|
||||
typeof raw === 'string' &&
|
||||
raw.length > 0 &&
|
||||
raw.length <= PRODUCT_NAME_MAX_LENGTH &&
|
||||
PRODUCT_NAME_PATTERN.test(raw)
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidProductCode(raw: string): boolean {
|
||||
return (
|
||||
typeof raw === 'string' &&
|
||||
raw.length > 0 &&
|
||||
raw.length <= PRODUCT_CODE_MAX_LENGTH &&
|
||||
PRODUCT_CODE_PATTERN.test(raw)
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidProductUnit(raw: string): boolean {
|
||||
return (
|
||||
typeof raw === 'string' &&
|
||||
raw.length > 0 &&
|
||||
raw.length <= PRODUCT_UNIT_MAX_LENGTH &&
|
||||
PRODUCT_UNIT_PATTERN.test(raw)
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidProductBrand(raw: string): boolean {
|
||||
return (
|
||||
typeof raw === 'string' &&
|
||||
raw.length > 0 &&
|
||||
raw.length <= PRODUCT_BRAND_MAX_LENGTH
|
||||
);
|
||||
}
|
||||
|
||||
/** RFC 4180-style record split that preserves commas inside quotes. */
|
||||
export function parseCsvRecord(line: string): string[] {
|
||||
const cells: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (inQuotes) {
|
||||
if (ch === '"') {
|
||||
if (line[i + 1] === '"') {
|
||||
current += '"';
|
||||
i += 1;
|
||||
} else {
|
||||
inQuotes = false;
|
||||
}
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
} else if (ch === '"') {
|
||||
inQuotes = true;
|
||||
} else if (ch === ',') {
|
||||
cells.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
cells.push(current.trim());
|
||||
return cells;
|
||||
}
|
||||
|
||||
export function isAllowedCsvUpload(file: {
|
||||
mimetype: string;
|
||||
originalname: string;
|
||||
}): boolean {
|
||||
return (
|
||||
file.mimetype.includes('csv') ||
|
||||
file.originalname.toLowerCase().endsWith('.csv')
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { UserRelation } from '../../../common/http/response';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
|
||||
export type Product = {
|
||||
readonly id: string;
|
||||
readonly code: string;
|
||||
readonly name: string;
|
||||
readonly unit: string | null;
|
||||
readonly price: Decimal | null;
|
||||
readonly brand: string | null;
|
||||
readonly status: Status;
|
||||
readonly createdAt: DateTime;
|
||||
readonly updatedAt: DateTime;
|
||||
readonly createdBy: string;
|
||||
readonly updatedBy: string;
|
||||
readonly createdByUser: UserRelation;
|
||||
readonly updatedByUser: UserRelation;
|
||||
};
|
||||
|
||||
export type CreateProductInput = {
|
||||
readonly code: string;
|
||||
readonly name: string;
|
||||
readonly unit?: string | null;
|
||||
readonly price?: Decimal | null;
|
||||
readonly brand?: string | null;
|
||||
readonly status?: Status;
|
||||
readonly userId: string;
|
||||
};
|
||||
|
||||
export type UpdateProductInput = {
|
||||
readonly code?: string;
|
||||
readonly name?: string;
|
||||
readonly unit?: string | null;
|
||||
readonly price?: Decimal | null;
|
||||
readonly brand?: string | null;
|
||||
readonly userId: string;
|
||||
};
|
||||
|
||||
export type ListProductsFilters = {
|
||||
readonly code?: string;
|
||||
readonly name?: string;
|
||||
readonly unit?: string;
|
||||
readonly brand?: string;
|
||||
readonly status?: string;
|
||||
readonly search?: string;
|
||||
readonly orderBy?: string;
|
||||
readonly orderType?: string;
|
||||
readonly limit: number;
|
||||
readonly offset: number;
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ProductsReadController } from './products-read.controller';
|
||||
import { ProductsService } from './products.service';
|
||||
|
||||
describe('ProductsReadController', () => {
|
||||
let controller: ProductsReadController;
|
||||
const service = {
|
||||
list: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
controllers: [ProductsReadController],
|
||||
providers: [{ provide: ProductsService, useValue: service }],
|
||||
}).compile();
|
||||
controller = moduleRef.get(ProductsReadController);
|
||||
});
|
||||
|
||||
it('list delegates to the service', async () => {
|
||||
service.list.mockResolvedValue({ data: [], total: 0 });
|
||||
await expect(controller.list({ page: 1 })).resolves.toEqual({
|
||||
data: [],
|
||||
total: 0,
|
||||
});
|
||||
expect(service.list).toHaveBeenCalledWith({ page: 1 });
|
||||
});
|
||||
|
||||
it('findOne delegates to the service', async () => {
|
||||
service.findById.mockResolvedValue({ id: 'emp-1' });
|
||||
await expect(controller.findOne('emp-1')).resolves.toEqual({
|
||||
id: 'emp-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiForbiddenResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
||||
import {
|
||||
Pagination,
|
||||
type PaginationResponse,
|
||||
PaginationMetaDto,
|
||||
} from '../../../common/http/response';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import { ProductDto, ListProductsQueryDto } from './dto/product.dto';
|
||||
import { ProductsService } from './products.service';
|
||||
|
||||
export const PRODUCT_PRIVILEGE_KEY = 'CONFIGURATION.PRODUCT';
|
||||
|
||||
@ApiTags('products')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('products')
|
||||
export class ProductsReadController {
|
||||
constructor(private readonly productsService: ProductsService) {}
|
||||
|
||||
@Get()
|
||||
@Pagination()
|
||||
@RequirePrivilege(PRODUCT_PRIVILEGE_KEY, 'view')
|
||||
@ApiOperation({ summary: 'List products' })
|
||||
@ApiOkResponse({
|
||||
schema: {
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/ProductDto' },
|
||||
},
|
||||
meta: { $ref: '#/components/schemas/PaginationMetaDto' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
list(
|
||||
@Query() query: ListProductsQueryDto,
|
||||
): Promise<PaginationResponse<ProductDto>> {
|
||||
return this.productsService.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePrivilege(PRODUCT_PRIVILEGE_KEY, 'view')
|
||||
@ApiOperation({ summary: 'Get product detail' })
|
||||
@ApiOkResponse({ type: ProductDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<ProductDto> {
|
||||
return this.productsService.findById(id);
|
||||
}
|
||||
}
|
||||
|
||||
void PaginationMetaDto;
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ProductsWriteController } from './products-write.controller';
|
||||
import { ProductsService } from './products.service';
|
||||
|
||||
const createDto = {
|
||||
code: 'PRD_01',
|
||||
name: 'Ada Lovelace',
|
||||
unit: 'L',
|
||||
price: '12500.0000',
|
||||
brand: 'Pertamina',
|
||||
};
|
||||
|
||||
describe('ProductsWriteController', () => {
|
||||
let controller: ProductsWriteController;
|
||||
const service = {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateStatus: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
bulkDelete: jest.fn(),
|
||||
bulkUpdateStatus: jest.fn(),
|
||||
importCsv: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
controllers: [ProductsWriteController],
|
||||
providers: [{ provide: ProductsService, useValue: service }],
|
||||
}).compile();
|
||||
controller = moduleRef.get(ProductsWriteController);
|
||||
});
|
||||
|
||||
it('create passes dto fields and user id', async () => {
|
||||
service.create.mockResolvedValue({ id: 'emp-1' });
|
||||
await controller.create(createDto, 'user-1');
|
||||
expect(service.create).toHaveBeenCalledWith({
|
||||
...createDto,
|
||||
status: undefined,
|
||||
userId: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('update, updateStatus, and delete delegate', async () => {
|
||||
service.update.mockResolvedValue({ id: 'emp-1' });
|
||||
service.updateStatus.mockResolvedValue({ id: 'emp-1' });
|
||||
service.delete.mockResolvedValue(undefined);
|
||||
await controller.update('emp-1', { name: 'Ada Lovelace' }, 'user-1');
|
||||
await controller.updateStatus('emp-1', { status: 'active' }, 'user-1');
|
||||
await controller.delete('emp-1');
|
||||
expect(service.updateStatus).toHaveBeenCalledWith(
|
||||
'emp-1',
|
||||
'active',
|
||||
'user-1',
|
||||
);
|
||||
expect(service.delete).toHaveBeenCalledWith('emp-1');
|
||||
});
|
||||
|
||||
it('bulk and import delegate', async () => {
|
||||
service.bulkDelete.mockResolvedValue({ deleted: 1 });
|
||||
service.bulkUpdateStatus.mockResolvedValue({ updated: 1 });
|
||||
service.importCsv.mockResolvedValue({ imported: 1 });
|
||||
await controller.bulkDelete({ ids: ['emp-1'] });
|
||||
await controller.bulkStatus(
|
||||
{ ids: ['emp-1'], status: 'archived' },
|
||||
'user-1',
|
||||
);
|
||||
await controller.importCsv(
|
||||
{
|
||||
buffer: Buffer.from(
|
||||
'code,name,phone,position\nPRD_01,Ada,+6281234567890,sales',
|
||||
),
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
expect(service.importCsv).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('importCsv uses empty string when file is missing', async () => {
|
||||
service.importCsv.mockResolvedValue({ imported: 0 });
|
||||
await controller.importCsv(undefined, 'user-1');
|
||||
expect(service.importCsv).toHaveBeenCalledWith('', 'user-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiCreatedResponse,
|
||||
ApiForbiddenResponse,
|
||||
ApiNoContentResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import { isAllowedCsvUpload } from './product-fields';
|
||||
import { PRODUCT_PRIVILEGE_KEY } from './products-read.controller';
|
||||
import { ProductsService } from './products.service';
|
||||
import {
|
||||
BulkIdsDto,
|
||||
BulkStatusDto,
|
||||
CreateProductDto,
|
||||
ProductDto,
|
||||
UpdateProductDto,
|
||||
UpdateProductStatusDto,
|
||||
} from './dto/product.dto';
|
||||
|
||||
@ApiTags('products')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('products')
|
||||
export class ProductsWriteController {
|
||||
constructor(private readonly productsService: ProductsService) {}
|
||||
|
||||
@Post('import')
|
||||
@RequirePrivilege(PRODUCT_PRIVILEGE_KEY, 'import')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: 1_048_576 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (!isAllowedCsvUpload(file)) {
|
||||
cb(new BadRequestException('Only CSV files are allowed'), false);
|
||||
return;
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
}),
|
||||
)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: { type: 'string', format: 'binary' },
|
||||
},
|
||||
required: ['file'],
|
||||
},
|
||||
})
|
||||
@ApiOperation({ summary: 'Import products from CSV' })
|
||||
@ApiOkResponse({
|
||||
schema: { properties: { imported: { type: 'number' } } },
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
importCsv(
|
||||
@UploadedFile() file: { buffer?: Buffer } | undefined,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<{ imported: number }> {
|
||||
const csv = file?.buffer?.toString('utf8') ?? '';
|
||||
return this.productsService.importCsv(csv, userId);
|
||||
}
|
||||
|
||||
@Post('bulk-delete')
|
||||
@HttpCode(200)
|
||||
@RequirePrivilege(PRODUCT_PRIVILEGE_KEY, 'delete')
|
||||
@ApiOperation({ summary: 'Bulk delete products' })
|
||||
@ApiOkResponse({
|
||||
schema: { properties: { deleted: { type: 'number' } } },
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
bulkDelete(@Body() dto: BulkIdsDto): Promise<{ deleted: number }> {
|
||||
return this.productsService.bulkDelete(dto.ids);
|
||||
}
|
||||
|
||||
@Post('bulk-status')
|
||||
@HttpCode(200)
|
||||
@RequirePrivilege(PRODUCT_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Bulk update product status' })
|
||||
@ApiOkResponse({
|
||||
schema: { properties: { updated: { type: 'number' } } },
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
bulkStatus(
|
||||
@Body() dto: BulkStatusDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<{ updated: number }> {
|
||||
return this.productsService.bulkUpdateStatus(dto.ids, dto.status, userId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePrivilege(PRODUCT_PRIVILEGE_KEY, 'create')
|
||||
@ApiOperation({ summary: 'Create product' })
|
||||
@ApiCreatedResponse({ type: ProductDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
create(
|
||||
@Body() dto: CreateProductDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<ProductDto> {
|
||||
return this.productsService.create({
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
unit: dto.unit,
|
||||
price: dto.price,
|
||||
brand: dto.brand,
|
||||
status: dto.status,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@RequirePrivilege(PRODUCT_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Update product status' })
|
||||
@ApiOkResponse({ type: ProductDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
updateStatus(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateProductStatusDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<ProductDto> {
|
||||
return this.productsService.updateStatus(id, dto.status, userId);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePrivilege(PRODUCT_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Update product (not status)' })
|
||||
@ApiOkResponse({ type: ProductDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateProductDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<ProductDto> {
|
||||
return this.productsService.update(id, {
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
unit: dto.unit,
|
||||
price: dto.price,
|
||||
brand: dto.brand,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@RequirePrivilege(PRODUCT_PRIVILEGE_KEY, 'delete')
|
||||
@ApiOperation({ summary: 'Delete product' })
|
||||
@ApiNoContentResponse()
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
async delete(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||
await this.productsService.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ProductsReadController } from './products-read.controller';
|
||||
import { ProductsWriteController } from './products-write.controller';
|
||||
import { ProductsRepository } from './products.repository';
|
||||
import { ProductsService } from './products.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ProductsReadController, ProductsWriteController],
|
||||
providers: [ProductsRepository, ProductsService],
|
||||
exports: [ProductsService],
|
||||
})
|
||||
export class ProductsModule {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user