Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82e4a0cbf0 | ||
|
|
ec5f012d6f | ||
|
|
4b48dbdf3c | ||
|
|
9428a983f5 | ||
|
|
6b3ddfcff9 | ||
|
|
51db4f4a4d | ||
|
|
365a37b8d2 | ||
|
|
0e73d14381 | ||
|
|
23028abd48 | ||
|
|
2955b974d2 | ||
|
|
5579cf6566 |
@@ -11,7 +11,7 @@ alwaysApply: false
|
||||
Every **non-public** controller handler on a primary (CRUD) resource MUST use:
|
||||
|
||||
```typescript
|
||||
@RequirePrivilege('MODULE.RESOURCE', 'view' | 'create' | 'update' | 'delete' | 'import')
|
||||
@RequirePrivilege('GROUP.PARENT.MODULE' | ['ADMIN.SALES.ACTIVITIES.PLAN', 'MOBILE.SALES.PLAN'], 'view' | 'create' | 'update' | 'delete' | 'import')
|
||||
```
|
||||
|
||||
Map HTTP verbs to actions:
|
||||
@@ -24,13 +24,13 @@ Map HTTP verbs to actions:
|
||||
| `DELETE /:id`, bulk-delete | `delete` |
|
||||
| `POST /import` | `import` |
|
||||
|
||||
Key codes use dotted uppercase module levels (`PRIVILEGES`, `SALES.INVOICE`). New modules add a `privilege_keys` seed row via migration — do not invent a parallel permission helper.
|
||||
Key codes use 3- or 4-part dotted uppercase hierarchy: `Group.Parent.Module` or `Group.Parent.Module.Submodule` (e.g. `ADMIN.SALES.ACTIVITIES.INVOICE`, `MOBILE.SALES.PLAN`). Pass a string or string array to `@RequirePrivilege`; arrays use OR semantics. New modules add `privilege_keys` rows via migration — do not invent a parallel permission helper.
|
||||
|
||||
Seed an Administrator privilege only via SQL/ops after the first user exists (`created_by` requires a user). Documented bootstrap: insert privilege + details, then `UPDATE users SET privilege_id = …`. Do not auto-grant on register.
|
||||
|
||||
## Guard behavior
|
||||
|
||||
`PrivilegesGuard` (global) allows when there is no metadata. When metadata is present, `users.is_superadmin === true` skips the matrix check. Otherwise the user’s assigned privilege must be **status `active`** and the matrix cell must be `value === true`, or the request is `403 Forbidden`. Missing privilege / draft / archived / missing cell / `false` → deny.
|
||||
`PrivilegesGuard` (global) allows when there is no metadata. When metadata is present, `users.is_superadmin === true` skips the matrix check. Otherwise the user’s assigned privilege must be **status `active`** and at least one matrix cell in the required key list must be `value === true`, or the request is `403 Forbidden`. Missing privilege / draft / archived / missing cell / `false` → deny.
|
||||
|
||||
Do not set `is_superadmin` via register/login. Default is `false`; promote via SQL/ops (`UPDATE users SET is_superadmin = true`). The flag is loaded from the database on each JWT validation (not from JWT claims).
|
||||
|
||||
|
||||
@@ -23,3 +23,7 @@ BCRYPT_SALT_ROUNDS=10
|
||||
# OpenAPI UI at /docs (default: on unless NODE_ENV=production)
|
||||
# SWAGGER_ENABLED=true
|
||||
# SWAGGER_ENABLED=false
|
||||
|
||||
# Debug only. Skip GPS radius/location checks on check-in and check-out.
|
||||
# Rejected when NODE_ENV=production.
|
||||
# SKIP_GPS_VALIDATION=true
|
||||
|
||||
+30
@@ -83,3 +83,33 @@ 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`.
|
||||
|
||||
## Reports
|
||||
|
||||
Privilege keys: `SALES.REPORT`, `LOGISTICS.REPORT` (seeded in migration `0013_reports`).
|
||||
|
||||
### `GET /reports/config` — bearer — `200`
|
||||
|
||||
Query: `groupNames` (e.g. `sales_report`). Returns report configs visible to the caller, each with optional `activeFilter` and `activeTableConfig` bookmarks.
|
||||
|
||||
### `POST /reports/data` — bearer — `200`
|
||||
|
||||
Body: `{ groupName, uniqueName, queryModel }`. Returns row array keyed by column id.
|
||||
|
||||
### `POST /reports/meta` — bearer — `200`
|
||||
|
||||
Same body as data. Returns `{ totalRow, limit, offset }`.
|
||||
|
||||
### Report bookmarks
|
||||
|
||||
| Method | Path | Notes |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/report-bookmarks` | List for current user (`@Pagination()`) |
|
||||
| `GET` | `/report-bookmarks/label-history` | Distinct labels |
|
||||
| `GET` | `/report-bookmarks/applied` | Query: `groupName`, `uniqueName`, `type` |
|
||||
| `POST` | `/report-bookmarks` | Create (`201`) |
|
||||
| `PUT` | `/report-bookmarks/applied/:id` | Apply (unapplies siblings) |
|
||||
| `PUT` | `/report-bookmarks/unapplied/:id` | Clear applied |
|
||||
| `DELETE` | `/report-bookmarks/:id` | `204` |
|
||||
|
||||
Bookmark `type`: `FILTER_TABLE` | `TABLE_CONFIG`. `configuration` is opaque JSON (filter form values or AG Grid column state).
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# TrackGo Report Engine
|
||||
|
||||
Config-driven reporting for `trackgo-be` (`src/modules/reports`) and `trackgo-fe` (`apps/web/src/core/report`).
|
||||
|
||||
## Architecture
|
||||
|
||||
1. **Report config** — TypeScript object per report (`shared/configs/`). Defines SQL `tableSchema`, columns, filters, and `privilegeKey`.
|
||||
2. **Query builder** — `ReportQueryBuilder` compiles AG Grid `queryModel` + config into parameterized Drizzle SQL (`db.execute`).
|
||||
3. **Generic UI** — `ReportProvider` loads configs for a `groupName` and renders one tab per report via `ReportTable` (AG Grid Server-Side Row Model).
|
||||
|
||||
Persisted engine data:
|
||||
|
||||
- `report_bookmarks` — saved filters (`FILTER_TABLE`) and table layouts (`TABLE_CONFIG`)
|
||||
|
||||
Report rows are **never** stored; they are queried live from business tables.
|
||||
|
||||
## Groups and privilege keys
|
||||
|
||||
| Group | `groupName` | Privilege key | Menu path |
|
||||
| --- | --- | --- | --- |
|
||||
| Sales reports | `sales_report` | `SALES.REPORT` | `/app/sales/reports/index` |
|
||||
| Logistics reports | `logistics_report` | `LOGISTICS.REPORT` | `/app/logistics/reports/index` |
|
||||
|
||||
## HTTP APIs
|
||||
|
||||
| Method | Path | Body / query |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/reports/config` | `groupNames` (array or repeated) |
|
||||
| `POST` | `/reports/data` | `{ groupName, uniqueName, queryModel }` |
|
||||
| `POST` | `/reports/meta` | same as data → `{ totalRow, limit, offset }` |
|
||||
| `GET` | `/report-bookmarks` | list filters (`groupName`, `uniqueName`, `type`, pagination) |
|
||||
| `POST` | `/report-bookmarks` | create bookmark |
|
||||
| `PUT` | `/report-bookmarks/applied/:id` | apply |
|
||||
| `PUT` | `/report-bookmarks/unapplied/:id` | unapply |
|
||||
| `DELETE` | `/report-bookmarks/:id` | delete |
|
||||
|
||||
All endpoints require JWT. Report data/config endpoints use `ReportPrivilegeGuard` (config `privilegeKey` + `view`). Bookmarks are scoped to `createdBy` (current user).
|
||||
|
||||
## Adding a report
|
||||
|
||||
1. Add a `ReportConfigEntity` file under `shared/configs/`.
|
||||
2. Register it in `shared/configs/index.ts`.
|
||||
3. No new controller or React page — the generic UI picks it up when `groupName` matches.
|
||||
|
||||
## TrackGo-specific notes
|
||||
|
||||
- JSON uses **camelCase** (`groupName`, `queryModel`, `columnConfigs`).
|
||||
- SQL values are **bound parameters**; only config-authored fragments use `sql.raw()`.
|
||||
- Cell formatting uses `DateTime`, `Status`, and `Decimal` value objects.
|
||||
- Excel export is **not** implemented in this phase.
|
||||
|
||||
See [report-list.md](./report-list.md) for the seven shipped reports.
|
||||
@@ -0,0 +1,104 @@
|
||||
# TrackGo Reports
|
||||
|
||||
Reports implemented in the report engine. Columns reflect **available data** only — fields from the legacy PMPS UI without backing tables are omitted.
|
||||
|
||||
## Sales reports (`sales_report`)
|
||||
|
||||
### Report Sales Order
|
||||
|
||||
| Column | Source |
|
||||
| --- | --- |
|
||||
| Date | `sales_orders.date` |
|
||||
| Branch | `branches.name` |
|
||||
| Division | `divisions.name` |
|
||||
| No. Sales Order | `sales_orders.code` |
|
||||
| Customer | `customers.name` |
|
||||
| Invoice Amount | `SUM(sales_order_products.quantity * price)` |
|
||||
| Sales Rep. | `employees.name` |
|
||||
| Last Status Order | `sales_orders.status` |
|
||||
|
||||
### Report Request Order
|
||||
|
||||
| Column | Source |
|
||||
| --- | --- |
|
||||
| Date | `sales_requests.date` |
|
||||
| Branch | `branches.name` |
|
||||
| Division | `divisions.name` |
|
||||
| No. Request Order | `sales_requests.code` |
|
||||
| Customer | `customers.name` |
|
||||
| Sales Rep. | `employees.name` |
|
||||
| Status | `sales_requests.status` |
|
||||
|
||||
### Report Invoice
|
||||
|
||||
| Column | Source |
|
||||
| --- | --- |
|
||||
| Date | `sales_invoices.date` |
|
||||
| Branch | `branches.name` |
|
||||
| Division | `divisions.name` |
|
||||
| Customer | `customers.name` |
|
||||
| Customer code | `customers.code` |
|
||||
| Sales Order No. | `sales_invoices.sales_order_code` |
|
||||
| Invoice No. | `sales_invoices.code` |
|
||||
| Status | `sales_invoices.status` |
|
||||
| Sales Rep. | `employees.name` |
|
||||
| Balance | `sales_invoices.balance` |
|
||||
|
||||
### Report Payment
|
||||
|
||||
| Column | Source |
|
||||
| --- | --- |
|
||||
| Date | `sales_payments.date` |
|
||||
| Payment No. | `sales_payments.code` |
|
||||
| Customer code | via `sales_invoices` → `customers.code` |
|
||||
| Branch | via invoice → `branches.name` |
|
||||
| Division | via invoice → `divisions.name` |
|
||||
| Sales Rep | via invoice → `employees.name` |
|
||||
| Invoice ID | `sales_invoices.code` |
|
||||
| Invoice Amount | `sales_invoices.balance` |
|
||||
| Payment Amount | `sales_payment_invoices.amount` |
|
||||
| Status | `sales_payments.status` |
|
||||
|
||||
### Report Visit Plan
|
||||
|
||||
| Column | Source |
|
||||
| --- | --- |
|
||||
| Date | `plans.date` (`purpose = sales`) |
|
||||
| Sales Rep | `employees.name` |
|
||||
| Branch | start branch name |
|
||||
| Plan | count of `plan_destinations` |
|
||||
| Invoice | count of `plan_invoices` |
|
||||
| Status | `plans.status` |
|
||||
|
||||
## Logistics reports (`logistics_report`)
|
||||
|
||||
### Report Packing Slip
|
||||
|
||||
| Column | Source |
|
||||
| --- | --- |
|
||||
| Date | `packing_slips.date` |
|
||||
| Sales Order No. | `packing_slips.sales_order_number` |
|
||||
| Packing Slip No. | `packing_slips.code` |
|
||||
| Customer | `customers.name` |
|
||||
| Status | `packing_slips.status` |
|
||||
|
||||
### Report Delivery Plan
|
||||
|
||||
| Column | Source |
|
||||
| --- | --- |
|
||||
| Date | `plans.date` (`purpose = logistics`) |
|
||||
| Sales Rep | `employees.name` (driver) |
|
||||
| Branch | start branch name |
|
||||
| Plan | count of `plan_destinations` |
|
||||
| Packing Slip | count of `plan_packing_slips` |
|
||||
| Status | `plans.status` |
|
||||
|
||||
## Not built (no backing data)
|
||||
|
||||
These reports from the legacy PMPS list require visit tracking, permissions, or alerts tables that do not exist in TrackGo:
|
||||
|
||||
- Report Performance (sales and logistic)
|
||||
- Report Sales Permission / Report Logistic Permission
|
||||
- Report Alert (sales and logistic)
|
||||
|
||||
Also omitted as columns everywhere: Visited, Break Time, Driving, Stop Time, Cancel, Alert counts, and live visit actuals.
|
||||
@@ -0,0 +1,21 @@
|
||||
CREATE TABLE "report_bookmarks" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"group_name" text NOT NULL,
|
||||
"unique_name" text NOT NULL,
|
||||
"label" text NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"applied" boolean DEFAULT false NOT NULL,
|
||||
"configuration" 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
|
||||
ALTER TABLE "report_bookmarks" ADD CONSTRAINT "report_bookmarks_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 "report_bookmarks" ADD CONSTRAINT "report_bookmarks_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 "report_bookmarks_owner_report_type_applied_unique" ON "report_bookmarks" USING btree ("created_by","group_name","unique_name","type") WHERE "applied" = true;--> statement-breakpoint
|
||||
INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES
|
||||
('SALES.REPORT', 'Sales reports', 18),
|
||||
('LOGISTICS.REPORT', 'Logistics reports', 19);
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "sales_invoices" ADD COLUMN "address" text DEFAULT '' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "sales_invoices" ALTER COLUMN "address" DROP DEFAULT;--> statement-breakpoint
|
||||
ALTER TABLE "sales_invoices" ADD COLUMN "latitude" double precision;--> statement-breakpoint
|
||||
ALTER TABLE "sales_invoices" ADD COLUMN "longitude" double precision;
|
||||
@@ -0,0 +1,94 @@
|
||||
ALTER TABLE "company_settings" ADD COLUMN "check_in_radius_meters" integer DEFAULT 100 NOT NULL;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "attendances" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"employee_id" uuid NOT NULL,
|
||||
"branch_id" uuid NOT NULL,
|
||||
"date" bigint NOT NULL,
|
||||
"check_in_at" bigint NOT NULL,
|
||||
"check_in_method" text NOT NULL,
|
||||
"check_in_latitude" double precision NOT NULL,
|
||||
"check_in_longitude" double precision NOT NULL,
|
||||
"check_in_photo_url" text,
|
||||
"check_in_distance_meters" integer,
|
||||
"check_out_at" bigint,
|
||||
"check_out_method" text,
|
||||
"check_out_latitude" double precision,
|
||||
"check_out_longitude" double precision,
|
||||
"check_out_photo_url" text,
|
||||
"check_out_distance_meters" integer,
|
||||
"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 "visits" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"employee_id" uuid NOT NULL,
|
||||
"customer_id" uuid NOT NULL,
|
||||
"attendance_id" uuid,
|
||||
"plan_id" uuid,
|
||||
"plan_destination_id" uuid,
|
||||
"date" bigint NOT NULL,
|
||||
"check_in_at" bigint NOT NULL,
|
||||
"check_in_method" text NOT NULL,
|
||||
"check_in_latitude" double precision NOT NULL,
|
||||
"check_in_longitude" double precision NOT NULL,
|
||||
"check_in_photo_url" text,
|
||||
"check_in_distance_meters" integer,
|
||||
"check_out_at" bigint,
|
||||
"check_out_method" text,
|
||||
"check_out_latitude" double precision,
|
||||
"check_out_longitude" double precision,
|
||||
"check_out_photo_url" text,
|
||||
"check_out_distance_meters" integer,
|
||||
"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 "attendances" ADD CONSTRAINT "attendances_employee_id_employees_id_fk" FOREIGN KEY ("employee_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "attendances" ADD CONSTRAINT "attendances_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches"("id") ON DELETE restrict ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "attendances" ADD CONSTRAINT "attendances_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 "attendances" ADD CONSTRAINT "attendances_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 "visits" ADD CONSTRAINT "visits_employee_id_employees_id_fk" FOREIGN KEY ("employee_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "visits" ADD CONSTRAINT "visits_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE restrict ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "visits" ADD CONSTRAINT "visits_attendance_id_attendances_id_fk" FOREIGN KEY ("attendance_id") REFERENCES "public"."attendances"("id") ON DELETE set null ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "visits" ADD CONSTRAINT "visits_plan_id_plans_id_fk" FOREIGN KEY ("plan_id") REFERENCES "public"."plans"("id") ON DELETE set null ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "visits" ADD CONSTRAINT "visits_plan_destination_id_plan_destinations_id_fk" FOREIGN KEY ("plan_destination_id") REFERENCES "public"."plan_destinations"("id") ON DELETE set null ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "visits" ADD CONSTRAINT "visits_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 "visits" ADD CONSTRAINT "visits_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 "attendances_employee_date_live_unique" ON "attendances" USING btree ("employee_id","date") WHERE "attendances"."status" <> 'archived';
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "attendances_employee_open_unique" ON "attendances" USING btree ("employee_id") WHERE "attendances"."check_out_at" IS NULL AND "attendances"."status" <> 'archived';
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "attendances_employee_id_idx" ON "attendances" USING btree ("employee_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "attendances_branch_id_idx" ON "attendances" USING btree ("branch_id");
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "visits_employee_open_unique" ON "visits" USING btree ("employee_id") WHERE "visits"."check_out_at" IS NULL AND "visits"."status" <> 'archived';
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "visits_employee_id_idx" ON "visits" USING btree ("employee_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "visits_customer_id_idx" ON "visits" USING btree ("customer_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "visits_attendance_id_idx" ON "visits" USING btree ("attendance_id");
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES
|
||||
('FIELD.ATTENDANCE', 'Branch attendance', 18),
|
||||
('FIELD.VISIT', 'Customer visits', 19);
|
||||
@@ -0,0 +1,51 @@
|
||||
-- Rename existing privilege_keys to Group.Parent.Module[.Submodule] hierarchy
|
||||
UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.USER.PRIVILEGES', "label" = 'Privileges', "sort_order" = 101 WHERE "code" = 'PRIVILEGES';
|
||||
--> statement-breakpoint
|
||||
UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.USER.USERS', "label" = 'Users', "sort_order" = 102 WHERE "code" = 'USERS';
|
||||
--> statement-breakpoint
|
||||
UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.DATA.DIVISION', "label" = 'Divisions', "sort_order" = 103 WHERE "code" = 'CONFIGURATION.DIVISION';
|
||||
--> statement-breakpoint
|
||||
UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.DATA.BRANCH', "label" = 'Branches', "sort_order" = 104 WHERE "code" = 'CONFIGURATION.BRANCH';
|
||||
--> statement-breakpoint
|
||||
UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.DATA.CUSTOMER', "label" = 'Customers', "sort_order" = 105 WHERE "code" = 'CONFIGURATION.CUSTOMER';
|
||||
--> statement-breakpoint
|
||||
UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.DATA.PRODUCT', "label" = 'Products', "sort_order" = 106 WHERE "code" = 'CONFIGURATION.PRODUCT';
|
||||
--> statement-breakpoint
|
||||
UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.DATA.SETTING', "label" = 'Company settings', "sort_order" = 107 WHERE "code" = 'CONFIGURATION.SETTING';
|
||||
--> statement-breakpoint
|
||||
UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.DATA.EMPLOYEE', "label" = 'Employees', "sort_order" = 111 WHERE "code" = 'CONFIGURATION.EMPLOYEE';
|
||||
--> statement-breakpoint
|
||||
UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.DATA.CYCLE', "label" = 'Sales cycles', "sort_order" = 112 WHERE "code" = 'SALES.CYCLE';
|
||||
--> statement-breakpoint
|
||||
UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.ACTIVITIES.REQUEST', "label" = 'Sales requests', "sort_order" = 113 WHERE "code" = 'SALES.REQUEST';
|
||||
--> statement-breakpoint
|
||||
UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.ACTIVITIES.ORDER', "label" = 'Sales orders', "sort_order" = 114 WHERE "code" = 'SALES.ORDER';
|
||||
--> statement-breakpoint
|
||||
UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.ACTIVITIES.INVOICE', "label" = 'Sales invoices', "sort_order" = 115 WHERE "code" = 'SALES.INVOICE';
|
||||
--> statement-breakpoint
|
||||
UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.ACTIVITIES.PAYMENT', "label" = 'Sales payments', "sort_order" = 116 WHERE "code" = 'SALES.PAYMENT';
|
||||
--> statement-breakpoint
|
||||
UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.ACTIVITIES.PLAN', "label" = 'Sales plans', "sort_order" = 117 WHERE "code" = 'SALES.PLAN';
|
||||
--> statement-breakpoint
|
||||
UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.REPORT', "label" = 'Sales reports', "sort_order" = 118 WHERE "code" = 'SALES.REPORT';
|
||||
--> statement-breakpoint
|
||||
UPDATE "privilege_keys" SET "code" = 'ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP', "label" = 'Packing slips', "sort_order" = 121 WHERE "code" = 'SALES.PACKING_SLIP';
|
||||
--> statement-breakpoint
|
||||
UPDATE "privilege_keys" SET "code" = 'ADMIN.LOGISTICS.DATA.CYCLE', "label" = 'Logistics cycles', "sort_order" = 122 WHERE "code" = 'LOGISTICS.CYCLE';
|
||||
--> statement-breakpoint
|
||||
UPDATE "privilege_keys" SET "code" = 'ADMIN.LOGISTICS.ACTIVITIES.PLAN', "label" = 'Logistics plans', "sort_order" = 123 WHERE "code" = 'LOGISTICS.PLAN';
|
||||
--> statement-breakpoint
|
||||
UPDATE "privilege_keys" SET "code" = 'ADMIN.LOGISTICS.REPORT', "label" = 'Logistics reports', "sort_order" = 124 WHERE "code" = 'LOGISTICS.REPORT';
|
||||
--> statement-breakpoint
|
||||
UPDATE "privilege_keys" SET "code" = 'MOBILE.SALES.PLAN.ATTENDANCE', "label" = 'Branch attendance', "sort_order" = 201 WHERE "code" = 'FIELD.ATTENDANCE';
|
||||
--> statement-breakpoint
|
||||
UPDATE "privilege_keys" SET "code" = 'MOBILE.SALES.VISIT', "label" = 'Customer visits', "sort_order" = 202 WHERE "code" = 'FIELD.VISIT';
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES
|
||||
('MOBILE.SALES.PLAN', 'Sales plans (mobile)', 203),
|
||||
('MOBILE.SALES.REQUEST', 'Sales requests (mobile)', 204),
|
||||
('MOBILE.SALES.ORDER', 'Sales orders (mobile)', 205),
|
||||
('MOBILE.SALES.INVOICE', 'Sales invoices (mobile)', 206),
|
||||
('MOBILE.SALES.PAYMENT', 'Sales payments (mobile)', 207),
|
||||
('MOBILE.SALES.CUSTOMER', 'Customers (mobile)', 208),
|
||||
('MOBILE.LOGISTICS.PLAN', 'Logistics plans (mobile)', 209);
|
||||
@@ -0,0 +1,42 @@
|
||||
ALTER TABLE "company_settings" ADD COLUMN "gps_interval_seconds" integer DEFAULT 5 NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "company_settings" ADD COLUMN "checkout_warning_radius_meters" integer DEFAULT 200 NOT NULL;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "timeline_footprints" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"employee_id" uuid NOT NULL,
|
||||
"latitude" double precision NOT NULL,
|
||||
"longitude" double precision NOT NULL,
|
||||
"recorded_at" bigint NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "timeline_activities" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"employee_id" uuid NOT NULL,
|
||||
"customer_id" uuid,
|
||||
"visit_id" uuid,
|
||||
"type" text NOT NULL,
|
||||
"source_type" text NOT NULL,
|
||||
"source_id" uuid NOT NULL,
|
||||
"latitude" double precision NOT NULL,
|
||||
"longitude" double precision NOT NULL,
|
||||
"recorded_at" bigint NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "timeline_footprints" ADD CONSTRAINT "timeline_footprints_employee_id_employees_id_fk" FOREIGN KEY ("employee_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "timeline_activities" ADD CONSTRAINT "timeline_activities_employee_id_employees_id_fk" FOREIGN KEY ("employee_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "timeline_activities" ADD CONSTRAINT "timeline_activities_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE set null ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "timeline_activities" ADD CONSTRAINT "timeline_activities_visit_id_visits_id_fk" FOREIGN KEY ("visit_id") REFERENCES "public"."visits"("id") ON DELETE set null ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "timeline_footprints_employee_recorded_idx" ON "timeline_footprints" USING btree ("employee_id","recorded_at");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "timeline_activities_employee_recorded_idx" ON "timeline_activities" USING btree ("employee_id","recorded_at");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "timeline_activities_visit_id_idx" ON "timeline_activities" USING btree ("visit_id");
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES
|
||||
('ADMIN.SALES.ACTIVITIES.TIMELINE', 'Sales timeline', 119),
|
||||
('MOBILE.SALES.TIMELINE', 'Sales timeline (mobile)', 210);
|
||||
@@ -92,6 +92,41 @@
|
||||
"when": 1787560000000,
|
||||
"tag": "0012_users_primary",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1787561000000,
|
||||
"tag": "0013_reports",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1787562000000,
|
||||
"tag": "0014_sales_invoice_location",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1787563000000,
|
||||
"tag": "0015_field_check_in",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1787564000000,
|
||||
"tag": "0016_privilege_key_hierarchy",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1787565000000,
|
||||
"tag": "0017_timeline",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -7,6 +7,7 @@ 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 { ReportsModule } from './modules/reports/reports.module';
|
||||
import { SalesModule } from './modules/sales/sales.module';
|
||||
import { PrivilegesModule } from './modules/privileges/privileges.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
@@ -24,6 +25,7 @@ import { UsersModule } from './modules/users/users.module';
|
||||
ConfigurationModule,
|
||||
SalesModule,
|
||||
FieldModule,
|
||||
ReportsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
||||
@@ -4,13 +4,22 @@ import type { PrivilegeAction } from '../../modules/privileges/privilege-action'
|
||||
export const REQUIRE_PRIVILEGE_KEY = 'requirePrivilege';
|
||||
|
||||
export type RequirePrivilegeMeta = {
|
||||
readonly key: string;
|
||||
readonly keys: readonly string[];
|
||||
readonly action: PrivilegeAction;
|
||||
};
|
||||
|
||||
/** Marks a handler as requiring a privilege matrix cell to be true. */
|
||||
export const RequirePrivilege = (key: string, action: PrivilegeAction) =>
|
||||
function normalizePrivilegeKeys(
|
||||
keys: string | readonly string[],
|
||||
): readonly string[] {
|
||||
return typeof keys === 'string' ? [keys] : keys;
|
||||
}
|
||||
|
||||
/** Marks a handler as requiring one or more privilege matrix cells (OR). */
|
||||
export const RequirePrivilege = (
|
||||
keys: string | readonly string[],
|
||||
action: PrivilegeAction,
|
||||
) =>
|
||||
SetMetadata(REQUIRE_PRIVILEGE_KEY, {
|
||||
key,
|
||||
keys: normalizePrivilegeKeys(keys),
|
||||
action,
|
||||
} satisfies RequirePrivilegeMeta);
|
||||
|
||||
@@ -12,14 +12,14 @@ import {
|
||||
import { PrivilegesGuard } from './privileges.guard';
|
||||
|
||||
describe('PrivilegesGuard', () => {
|
||||
const checkPermission = jest.fn();
|
||||
const checkAnyPermission = jest.fn();
|
||||
const getAllAndOverride = jest.fn();
|
||||
const reflector = {
|
||||
getAllAndOverride,
|
||||
} as unknown as Reflector;
|
||||
|
||||
const guard = new PrivilegesGuard(reflector, {
|
||||
checkPermission,
|
||||
checkAnyPermission,
|
||||
} as never);
|
||||
|
||||
const user: AuthUser = {
|
||||
@@ -46,26 +46,48 @@ describe('PrivilegesGuard', () => {
|
||||
it('allows when no RequirePrivilege metadata', async () => {
|
||||
getAllAndOverride.mockReturnValue(undefined);
|
||||
await expect(guard.canActivate(createContext(user))).resolves.toBe(true);
|
||||
expect(checkPermission).not.toHaveBeenCalled();
|
||||
expect(checkAnyPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows when permission value is true', async () => {
|
||||
const meta: RequirePrivilegeMeta = { key: 'PRIVILEGES', action: 'view' };
|
||||
it('allows when permission value is true for a single key', async () => {
|
||||
const meta: RequirePrivilegeMeta = {
|
||||
keys: ['ADMIN.SETTINGS.USER.PRIVILEGES'],
|
||||
action: 'view',
|
||||
};
|
||||
getAllAndOverride.mockReturnValue(meta);
|
||||
checkPermission.mockResolvedValue(true);
|
||||
checkAnyPermission.mockResolvedValue(true);
|
||||
|
||||
await expect(guard.canActivate(createContext(user))).resolves.toBe(true);
|
||||
expect(checkPermission).toHaveBeenCalledWith(
|
||||
expect(checkAnyPermission).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
'PRIVILEGES',
|
||||
['ADMIN.SETTINGS.USER.PRIVILEGES'],
|
||||
'view',
|
||||
);
|
||||
});
|
||||
|
||||
it('allows when any key in the list is granted', async () => {
|
||||
const meta: RequirePrivilegeMeta = {
|
||||
keys: ['ADMIN.SALES.ACTIVITIES.PLAN', 'MOBILE.SALES.PLAN'],
|
||||
action: 'view',
|
||||
};
|
||||
getAllAndOverride.mockReturnValue(meta);
|
||||
checkAnyPermission.mockResolvedValue(true);
|
||||
|
||||
await expect(guard.canActivate(createContext(user))).resolves.toBe(true);
|
||||
expect(checkAnyPermission).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
['ADMIN.SALES.ACTIVITIES.PLAN', 'MOBILE.SALES.PLAN'],
|
||||
'view',
|
||||
);
|
||||
});
|
||||
|
||||
it('forbids when permission is false or missing', async () => {
|
||||
const meta: RequirePrivilegeMeta = { key: 'PRIVILEGES', action: 'delete' };
|
||||
const meta: RequirePrivilegeMeta = {
|
||||
keys: ['ADMIN.SETTINGS.USER.PRIVILEGES'],
|
||||
action: 'delete',
|
||||
};
|
||||
getAllAndOverride.mockReturnValue(meta);
|
||||
checkPermission.mockResolvedValue(false);
|
||||
checkAnyPermission.mockResolvedValue(false);
|
||||
|
||||
await expect(guard.canActivate(createContext(user))).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
@@ -73,17 +95,23 @@ describe('PrivilegesGuard', () => {
|
||||
});
|
||||
|
||||
it('skips privilege lookup when user is superadmin', async () => {
|
||||
const meta: RequirePrivilegeMeta = { key: 'PRIVILEGES', action: 'delete' };
|
||||
const meta: RequirePrivilegeMeta = {
|
||||
keys: ['ADMIN.SETTINGS.USER.PRIVILEGES'],
|
||||
action: 'delete',
|
||||
};
|
||||
getAllAndOverride.mockReturnValue(meta);
|
||||
|
||||
await expect(
|
||||
guard.canActivate(createContext({ ...user, isSuperadmin: true })),
|
||||
).resolves.toBe(true);
|
||||
expect(checkPermission).not.toHaveBeenCalled();
|
||||
expect(checkAnyPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('unauthorized when metadata present but no user', async () => {
|
||||
const meta: RequirePrivilegeMeta = { key: 'PRIVILEGES', action: 'view' };
|
||||
const meta: RequirePrivilegeMeta = {
|
||||
keys: ['ADMIN.SETTINGS.USER.PRIVILEGES'],
|
||||
action: 'view',
|
||||
};
|
||||
getAllAndOverride.mockReturnValue(meta);
|
||||
|
||||
await expect(guard.canActivate(createContext())).rejects.toBeInstanceOf(
|
||||
|
||||
@@ -39,9 +39,9 @@ export class PrivilegesGuard implements CanActivate {
|
||||
return true;
|
||||
}
|
||||
|
||||
const allowed = await this.privilegesService.checkPermission(
|
||||
const allowed = await this.privilegesService.checkAnyPermission(
|
||||
user.id,
|
||||
required.key,
|
||||
required.keys,
|
||||
required.action,
|
||||
);
|
||||
if (!allowed) {
|
||||
|
||||
@@ -47,3 +47,4 @@ export {
|
||||
type OrderDefault,
|
||||
type OrderType,
|
||||
} from './order-clause';
|
||||
export { parseQueryIdList } from './parse-query-id-list';
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { parseQueryIdList } from './parse-query-id-list';
|
||||
|
||||
describe('parseQueryIdList', () => {
|
||||
it('splits comma-separated and array values', () => {
|
||||
expect(parseQueryIdList('cus-1,cus-2')).toEqual(['cus-1', 'cus-2']);
|
||||
expect(parseQueryIdList(['cus-1', 'cus-2'])).toEqual(['cus-1', 'cus-2']);
|
||||
expect(parseQueryIdList(['cus-1,cus-2', 'cus-3'])).toEqual([
|
||||
'cus-1',
|
||||
'cus-2',
|
||||
'cus-3',
|
||||
]);
|
||||
expect(parseQueryIdList('cus-1,cus-1,cus-2')).toEqual(['cus-1', 'cus-2']);
|
||||
});
|
||||
|
||||
it('returns undefined for empty input', () => {
|
||||
expect(parseQueryIdList(undefined)).toBeUndefined();
|
||||
expect(parseQueryIdList('')).toBeUndefined();
|
||||
expect(parseQueryIdList([])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
export function parseQueryIdList(value: unknown): string[] | undefined {
|
||||
if (value == null || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
const items = Array.isArray(value) ? value : [value];
|
||||
const ids = items
|
||||
.flatMap((item) => String(item).split(','))
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
return ids.length > 0 ? [...new Set(ids)] : undefined;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ describe('loadEnv', () => {
|
||||
expect(env.REFRESH_TOKEN_EXPIRES_IN_MS).toBe(7 * 24 * 60 * 60 * 1000);
|
||||
expect(env.BCRYPT_SALT_ROUNDS).toBe(10);
|
||||
expect(env.DEFAULT_TIMEZONE).toBe('GMT+7');
|
||||
expect(env.SKIP_GPS_VALIDATION).toBe(false);
|
||||
});
|
||||
|
||||
it('throws when DATABASE_URL is missing', () => {
|
||||
@@ -75,4 +76,33 @@ describe('loadEnv', () => {
|
||||
}),
|
||||
).toThrow('must match');
|
||||
});
|
||||
|
||||
it('enables SKIP_GPS_VALIDATION outside production', () => {
|
||||
const env = loadEnv({
|
||||
...valid,
|
||||
SKIP_GPS_VALIDATION: 'true',
|
||||
NODE_ENV: 'development',
|
||||
});
|
||||
|
||||
expect(env.SKIP_GPS_VALIDATION).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects SKIP_GPS_VALIDATION in production', () => {
|
||||
expect(() =>
|
||||
loadEnv({
|
||||
...valid,
|
||||
SKIP_GPS_VALIDATION: 'true',
|
||||
NODE_ENV: 'production',
|
||||
}),
|
||||
).toThrow('cannot be enabled in production');
|
||||
});
|
||||
|
||||
it('rejects invalid SKIP_GPS_VALIDATION values', () => {
|
||||
expect(() =>
|
||||
loadEnv({
|
||||
...valid,
|
||||
SKIP_GPS_VALIDATION: 'yes',
|
||||
}),
|
||||
).toThrow('must be true or false');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ export type AppEnv = {
|
||||
REFRESH_TOKEN_EXPIRES_IN_MS: number;
|
||||
BCRYPT_SALT_ROUNDS: number;
|
||||
DEFAULT_TIMEZONE: string;
|
||||
SKIP_GPS_VALIDATION: boolean;
|
||||
};
|
||||
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
@@ -37,6 +38,24 @@ function requireSecret(name: string, value: string | undefined): string {
|
||||
return secret;
|
||||
}
|
||||
|
||||
function parseBoolean(
|
||||
name: string,
|
||||
value: string | undefined,
|
||||
fallback: boolean,
|
||||
): boolean {
|
||||
if (value === undefined || value.trim() === '') {
|
||||
return fallback;
|
||||
}
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'true' || normalized === '1') {
|
||||
return true;
|
||||
}
|
||||
if (normalized === 'false' || normalized === '0') {
|
||||
return false;
|
||||
}
|
||||
throw new Error(`${name} must be true or false`);
|
||||
}
|
||||
|
||||
function parsePositiveInt(
|
||||
name: string,
|
||||
value: string | undefined,
|
||||
@@ -88,6 +107,15 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
|
||||
FIFTEEN_MINUTES_MS,
|
||||
);
|
||||
|
||||
const skipGpsValidation = parseBoolean(
|
||||
'SKIP_GPS_VALIDATION',
|
||||
source.SKIP_GPS_VALIDATION,
|
||||
false,
|
||||
);
|
||||
if (source.NODE_ENV === 'production' && skipGpsValidation) {
|
||||
throw new Error('SKIP_GPS_VALIDATION cannot be enabled in production');
|
||||
}
|
||||
|
||||
return {
|
||||
PORT: parsePositiveInt('PORT', source.PORT, 3000),
|
||||
DATABASE_URL: requireString('DATABASE_URL', source.DATABASE_URL),
|
||||
@@ -108,6 +136,7 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
|
||||
10,
|
||||
),
|
||||
DEFAULT_TIMEZONE: source.DEFAULT_TIMEZONE?.trim() || 'GMT+7',
|
||||
SKIP_GPS_VALIDATION: skipGpsValidation,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { bigint, index, pgTable, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
|
||||
import { branches } from './branches-table';
|
||||
import { checkInColumns, checkOutColumns } from './checkpoint-columns';
|
||||
import { employees } from './employees-table';
|
||||
import { primaryEntityColumns } from './primary-entity-columns';
|
||||
import { users } from './schema';
|
||||
|
||||
export const attendances = pgTable(
|
||||
'attendances',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
employeeId: uuid('employee_id')
|
||||
.notNull()
|
||||
.references(() => employees.id, { onDelete: 'restrict' }),
|
||||
branchId: uuid('branch_id')
|
||||
.notNull()
|
||||
.references(() => branches.id, { onDelete: 'restrict' }),
|
||||
date: bigint('date', { mode: 'number' }).notNull(),
|
||||
...checkInColumns,
|
||||
...checkOutColumns,
|
||||
...primaryEntityColumns(users),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('attendances_employee_date_live_unique')
|
||||
.on(t.employeeId, t.date)
|
||||
.where(sql`${t.status} <> 'archived'`),
|
||||
uniqueIndex('attendances_employee_open_unique')
|
||||
.on(t.employeeId)
|
||||
.where(sql`${t.checkOutAt} IS NULL AND ${t.status} <> 'archived'`),
|
||||
index('attendances_employee_id_idx').on(t.employeeId),
|
||||
index('attendances_branch_id_idx').on(t.branchId),
|
||||
],
|
||||
);
|
||||
|
||||
export type AttendanceRow = typeof attendances.$inferSelect;
|
||||
export type NewAttendanceRow = typeof attendances.$inferInsert;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { bigint, doublePrecision, integer, text } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const checkInColumns = {
|
||||
checkInAt: bigint('check_in_at', { mode: 'number' }).notNull(),
|
||||
checkInMethod: text('check_in_method').notNull(),
|
||||
checkInLatitude: doublePrecision('check_in_latitude').notNull(),
|
||||
checkInLongitude: doublePrecision('check_in_longitude').notNull(),
|
||||
checkInPhotoUrl: text('check_in_photo_url'),
|
||||
checkInDistanceMeters: integer('check_in_distance_meters'),
|
||||
};
|
||||
|
||||
export const checkOutColumns = {
|
||||
checkOutAt: bigint('check_out_at', { mode: 'number' }),
|
||||
checkOutMethod: text('check_out_method'),
|
||||
checkOutLatitude: doublePrecision('check_out_latitude'),
|
||||
checkOutLongitude: doublePrecision('check_out_longitude'),
|
||||
checkOutPhotoUrl: text('check_out_photo_url'),
|
||||
checkOutDistanceMeters: integer('check_out_distance_meters'),
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { bigint, pgTable, uuid } from 'drizzle-orm/pg-core';
|
||||
import { bigint, integer, pgTable, uuid } from 'drizzle-orm/pg-core';
|
||||
import { primaryEntityColumns } from './primary-entity-columns';
|
||||
import { users } from './schema';
|
||||
|
||||
@@ -8,6 +8,11 @@ import { users } from './schema';
|
||||
export const companySettings = pgTable('company_settings', {
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
cycleStartDate: bigint('cycle_start_date', { mode: 'number' }).notNull(),
|
||||
checkInRadiusMeters: integer('check_in_radius_meters').notNull().default(100),
|
||||
gpsIntervalSeconds: integer('gps_interval_seconds').notNull().default(5),
|
||||
checkoutWarningRadiusMeters: integer('checkout_warning_radius_meters')
|
||||
.notNull()
|
||||
.default(200),
|
||||
...primaryEntityColumns(users),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {
|
||||
pgTable,
|
||||
text,
|
||||
uuid,
|
||||
boolean,
|
||||
jsonb,
|
||||
uniqueIndex,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { primaryEntityColumns } from './primary-entity-columns';
|
||||
import { users } from './schema';
|
||||
|
||||
export const reportBookmarks = pgTable(
|
||||
'report_bookmarks',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
groupName: text('group_name').notNull(),
|
||||
uniqueName: text('unique_name').notNull(),
|
||||
label: text('label').notNull(),
|
||||
type: text('type').notNull(),
|
||||
applied: boolean('applied').notNull().default(false),
|
||||
configuration: jsonb('configuration').notNull(),
|
||||
...primaryEntityColumns(users),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('report_bookmarks_owner_report_type_applied_unique')
|
||||
.on(t.createdBy, t.groupName, t.uniqueName, t.type)
|
||||
.where(sql`${t.applied} = true`),
|
||||
],
|
||||
);
|
||||
|
||||
export type ReportBookmarkRow = typeof reportBookmarks.$inferSelect;
|
||||
export type NewReportBookmarkRow = typeof reportBookmarks.$inferInsert;
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
bigint,
|
||||
doublePrecision,
|
||||
index,
|
||||
numeric,
|
||||
pgTable,
|
||||
@@ -44,6 +45,9 @@ export const salesInvoices = pgTable(
|
||||
}),
|
||||
packingSlipCode: varchar('packing_slip_code', { length: 32 }),
|
||||
balance: numeric('balance', { precision: 18, scale: 4 }).notNull(),
|
||||
address: text('address').notNull(),
|
||||
latitude: doublePrecision('latitude'),
|
||||
longitude: doublePrecision('longitude'),
|
||||
notes: text('notes'),
|
||||
...primaryEntityColumns(users),
|
||||
},
|
||||
|
||||
@@ -247,3 +247,26 @@ export {
|
||||
type PlanPackingSlipRow,
|
||||
type PlanRow,
|
||||
} from './plans-table';
|
||||
export {
|
||||
attendances,
|
||||
type AttendanceRow,
|
||||
type NewAttendanceRow,
|
||||
} from './attendances-table';
|
||||
export { visits, type VisitRow, type NewVisitRow } from './visits-table';
|
||||
export {
|
||||
timelineFootprints,
|
||||
type TimelineFootprintRow,
|
||||
type NewTimelineFootprintRow,
|
||||
} from './timeline-footprints-table';
|
||||
export {
|
||||
timelineActivities,
|
||||
TIMELINE_ACTIVITY_TYPES,
|
||||
type TimelineActivityType,
|
||||
type TimelineActivityRow,
|
||||
type NewTimelineActivityRow,
|
||||
} from './timeline-activities-table';
|
||||
export {
|
||||
reportBookmarks,
|
||||
type NewReportBookmarkRow,
|
||||
type ReportBookmarkRow,
|
||||
} from './report-bookmarks-table';
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
bigint,
|
||||
doublePrecision,
|
||||
index,
|
||||
pgTable,
|
||||
text,
|
||||
uuid,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { customers } from './customers-table';
|
||||
import { employees } from './employees-table';
|
||||
import { visits } from './visits-table';
|
||||
|
||||
export const TIMELINE_ACTIVITY_TYPES = [
|
||||
'branch_check_in',
|
||||
'branch_check_out',
|
||||
'customer_check_in',
|
||||
'customer_check_out',
|
||||
'sales_order_created',
|
||||
'sales_request_created',
|
||||
'sales_payment_created',
|
||||
'customer_created',
|
||||
] as const;
|
||||
|
||||
export type TimelineActivityType = (typeof TIMELINE_ACTIVITY_TYPES)[number];
|
||||
|
||||
export const timelineActivities = pgTable(
|
||||
'timeline_activities',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
employeeId: uuid('employee_id')
|
||||
.notNull()
|
||||
.references(() => employees.id, { onDelete: 'restrict' }),
|
||||
customerId: uuid('customer_id').references(() => customers.id, {
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
visitId: uuid('visit_id').references(() => visits.id, {
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
type: text('type').notNull(),
|
||||
sourceType: text('source_type').notNull(),
|
||||
sourceId: uuid('source_id').notNull(),
|
||||
latitude: doublePrecision('latitude').notNull(),
|
||||
longitude: doublePrecision('longitude').notNull(),
|
||||
recordedAt: bigint('recorded_at', { mode: 'number' }).notNull(),
|
||||
},
|
||||
(t) => [
|
||||
index('timeline_activities_employee_recorded_idx').on(
|
||||
t.employeeId,
|
||||
t.recordedAt,
|
||||
),
|
||||
index('timeline_activities_visit_id_idx').on(t.visitId),
|
||||
],
|
||||
);
|
||||
|
||||
export type TimelineActivityRow = typeof timelineActivities.$inferSelect;
|
||||
export type NewTimelineActivityRow = typeof timelineActivities.$inferInsert;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { bigint, doublePrecision, index, pgTable, uuid } from 'drizzle-orm/pg-core';
|
||||
import { employees } from './employees-table';
|
||||
|
||||
export const timelineFootprints = pgTable(
|
||||
'timeline_footprints',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
employeeId: uuid('employee_id')
|
||||
.notNull()
|
||||
.references(() => employees.id, { onDelete: 'restrict' }),
|
||||
latitude: doublePrecision('latitude').notNull(),
|
||||
longitude: doublePrecision('longitude').notNull(),
|
||||
recordedAt: bigint('recorded_at', { mode: 'number' }).notNull(),
|
||||
},
|
||||
(t) => [
|
||||
index('timeline_footprints_employee_recorded_idx').on(
|
||||
t.employeeId,
|
||||
t.recordedAt,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export type TimelineFootprintRow = typeof timelineFootprints.$inferSelect;
|
||||
export type NewTimelineFootprintRow = typeof timelineFootprints.$inferInsert;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { bigint, index, pgTable, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
|
||||
import { attendances } from './attendances-table';
|
||||
import { checkInColumns, checkOutColumns } from './checkpoint-columns';
|
||||
import { customers } from './customers-table';
|
||||
import { employees } from './employees-table';
|
||||
import { planDestinations, plans } from './plans-table';
|
||||
import { primaryEntityColumns } from './primary-entity-columns';
|
||||
import { users } from './schema';
|
||||
|
||||
export const visits = pgTable(
|
||||
'visits',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
employeeId: uuid('employee_id')
|
||||
.notNull()
|
||||
.references(() => employees.id, { onDelete: 'restrict' }),
|
||||
customerId: uuid('customer_id')
|
||||
.notNull()
|
||||
.references(() => customers.id, { onDelete: 'restrict' }),
|
||||
attendanceId: uuid('attendance_id').references(() => attendances.id, {
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
planId: uuid('plan_id').references(() => plans.id, {
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
planDestinationId: uuid('plan_destination_id').references(
|
||||
() => planDestinations.id,
|
||||
{ onDelete: 'set null' },
|
||||
),
|
||||
date: bigint('date', { mode: 'number' }).notNull(),
|
||||
...checkInColumns,
|
||||
...checkOutColumns,
|
||||
...primaryEntityColumns(users),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('visits_employee_open_unique')
|
||||
.on(t.employeeId)
|
||||
.where(sql`${t.checkOutAt} IS NULL AND ${t.status} <> 'archived'`),
|
||||
index('visits_employee_id_idx').on(t.employeeId),
|
||||
index('visits_customer_id_idx').on(t.customerId),
|
||||
index('visits_attendance_id_idx').on(t.attendanceId),
|
||||
],
|
||||
);
|
||||
|
||||
export type VisitRow = typeof visits.$inferSelect;
|
||||
export type NewVisitRow = typeof visits.$inferInsert;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { PrivilegesService } from '../privileges/privileges.service';
|
||||
import { EmployeesService } from '../configuration/employees/employees.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
@@ -13,6 +14,9 @@ describe('AuthController', () => {
|
||||
let privilegesService: jest.Mocked<
|
||||
Pick<PrivilegesService, 'findPrivilegeSummary' | 'getPermissionsMap'>
|
||||
>;
|
||||
let employeesService: jest.Mocked<
|
||||
Pick<EmployeesService, 'findRelationByUserId'>
|
||||
>;
|
||||
|
||||
beforeEach(async () => {
|
||||
authService = {
|
||||
@@ -43,6 +47,9 @@ describe('AuthController', () => {
|
||||
findPrivilegeSummary: jest.fn(),
|
||||
getPermissionsMap: jest.fn(),
|
||||
};
|
||||
employeesService = {
|
||||
findRelationByUserId: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AuthController],
|
||||
@@ -50,6 +57,7 @@ describe('AuthController', () => {
|
||||
{ provide: AuthService, useValue: authService },
|
||||
{ provide: UsersService, useValue: usersService },
|
||||
{ provide: PrivilegesService, useValue: privilegesService },
|
||||
{ provide: EmployeesService, useValue: employeesService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -91,6 +99,7 @@ describe('AuthController', () => {
|
||||
username: 'alice',
|
||||
isSuperadmin: false,
|
||||
privilege: null,
|
||||
employee: null,
|
||||
permissions: {},
|
||||
});
|
||||
});
|
||||
@@ -109,7 +118,7 @@ describe('AuthController', () => {
|
||||
status: 'active',
|
||||
});
|
||||
privilegesService.getPermissionsMap.mockResolvedValue({
|
||||
PRIVILEGES: {
|
||||
'ADMIN.SETTINGS.USER.PRIVILEGES': {
|
||||
view: true,
|
||||
create: true,
|
||||
update: true,
|
||||
@@ -128,7 +137,7 @@ describe('AuthController', () => {
|
||||
).resolves.toMatchObject({
|
||||
privilege: { id: 'priv-1', code: 'ADMIN' },
|
||||
permissions: {
|
||||
PRIVILEGES: expect.objectContaining({ view: true }),
|
||||
'ADMIN.SETTINGS.USER.PRIVILEGES': expect.objectContaining({ view: true }),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -153,6 +162,7 @@ describe('AuthController', () => {
|
||||
username: 'alice',
|
||||
isSuperadmin: true,
|
||||
privilege: null,
|
||||
employee: null,
|
||||
permissions: {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
import { BEARER_AUTH_NAME } from '../../common/swagger/setup-swagger';
|
||||
import { PrivilegesService } from '../privileges/privileges.service';
|
||||
import { EmployeesService } from '../configuration/employees/employees.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import {
|
||||
@@ -35,6 +36,7 @@ export class AuthController {
|
||||
private readonly authService: AuthService,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly privilegesService: PrivilegesService,
|
||||
private readonly employeesService: EmployeesService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@@ -97,12 +99,15 @@ export class AuthController {
|
||||
async me(@CurrentUser() user: AuthUser): Promise<MeResponseDto> {
|
||||
const full = await this.usersService.findById(user.id);
|
||||
const isSuperadmin = full?.isSuperadmin ?? user.isSuperadmin;
|
||||
const employee = await this.employeesService.findRelationByUserId(user.id);
|
||||
|
||||
if (!full?.privilegeId) {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
isSuperadmin,
|
||||
privilege: null,
|
||||
employee,
|
||||
permissions: {},
|
||||
};
|
||||
}
|
||||
@@ -119,6 +124,7 @@ export class AuthController {
|
||||
username: user.username,
|
||||
isSuperadmin,
|
||||
privilege,
|
||||
employee,
|
||||
permissions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { PrivilegesGuard } from '../../common/guards/privileges.guard';
|
||||
import { PrivilegesModule } from '../privileges/privileges.module';
|
||||
import { EmployeesModule } from '../configuration/employees/employees.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
@@ -17,6 +18,7 @@ import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
EmployeesModule,
|
||||
PrivilegesModule,
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
|
||||
@@ -90,6 +90,20 @@ export class MePrivilegeDto {
|
||||
code!: string;
|
||||
}
|
||||
|
||||
export class MeEmployeeDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
code!: string;
|
||||
|
||||
@ApiProperty()
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: 'sales' })
|
||||
position!: string;
|
||||
}
|
||||
|
||||
export class MeResponseDto {
|
||||
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
|
||||
id!: string;
|
||||
@@ -103,10 +117,13 @@ export class MeResponseDto {
|
||||
@ApiProperty({ type: MePrivilegeDto, nullable: true })
|
||||
privilege!: MePrivilegeDto | null;
|
||||
|
||||
@ApiProperty({ type: MeEmployeeDto, nullable: true })
|
||||
employee!: MeEmployeeDto | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Permission matrix keyed by privilege key code',
|
||||
example: {
|
||||
PRIVILEGES: {
|
||||
'ADMIN.SETTINGS.USER.PRIVILEGES': {
|
||||
view: true,
|
||||
create: false,
|
||||
update: false,
|
||||
|
||||
@@ -18,7 +18,7 @@ import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import { BranchDto, ListBranchesQueryDto } from './dto/branch.dto';
|
||||
import { BranchesService } from './branches.service';
|
||||
|
||||
export const BRANCH_PRIVILEGE_KEY = 'CONFIGURATION.BRANCH';
|
||||
export const BRANCH_PRIVILEGE_KEY = 'ADMIN.SETTINGS.DATA.BRANCH';
|
||||
|
||||
@ApiTags('branches')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
|
||||
@@ -18,7 +18,10 @@ 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';
|
||||
export const CUSTOMER_PRIVILEGE_KEYS = [
|
||||
'ADMIN.SETTINGS.DATA.CUSTOMER',
|
||||
'MOBILE.SALES.CUSTOMER',
|
||||
] as const;
|
||||
|
||||
@ApiTags('customers')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@@ -28,7 +31,7 @@ export class CustomersReadController {
|
||||
|
||||
@Get()
|
||||
@Pagination()
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'view')
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'view')
|
||||
@ApiOperation({ summary: 'List customers' })
|
||||
@ApiOkResponse({
|
||||
schema: {
|
||||
@@ -50,7 +53,7 @@ export class CustomersReadController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'view')
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'view')
|
||||
@ApiOperation({ summary: 'Get customer detail' })
|
||||
@ApiOkResponse({ type: CustomerDto })
|
||||
@ApiNotFoundResponse()
|
||||
|
||||
@@ -29,7 +29,7 @@ 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 { CUSTOMER_PRIVILEGE_KEYS } from './customers-read.controller';
|
||||
import { CustomersService } from './customers.service';
|
||||
import {
|
||||
BulkIdsDto,
|
||||
@@ -49,7 +49,7 @@ export class CustomersWriteController {
|
||||
constructor(private readonly customersService: CustomersService) {}
|
||||
|
||||
@Post('import')
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'import')
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'import')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: 1_048_576 },
|
||||
@@ -88,7 +88,7 @@ export class CustomersWriteController {
|
||||
|
||||
@Post('bulk-delete')
|
||||
@HttpCode(200)
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'delete')
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'delete')
|
||||
@ApiOperation({ summary: 'Bulk delete customers' })
|
||||
@ApiOkResponse({
|
||||
schema: { properties: { deleted: { type: 'number' } } },
|
||||
@@ -101,7 +101,7 @@ export class CustomersWriteController {
|
||||
|
||||
@Post('bulk-status')
|
||||
@HttpCode(200)
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update')
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update')
|
||||
@ApiOperation({ summary: 'Bulk update customer status' })
|
||||
@ApiOkResponse({
|
||||
schema: { properties: { updated: { type: 'number' } } },
|
||||
@@ -116,7 +116,7 @@ export class CustomersWriteController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'create')
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'create')
|
||||
@ApiOperation({ summary: 'Create customer' })
|
||||
@ApiCreatedResponse({ type: CustomerDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@@ -132,7 +132,7 @@ export class CustomersWriteController {
|
||||
}
|
||||
|
||||
@Post(':id/contacts')
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update')
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update')
|
||||
@ApiOperation({ summary: 'Add a customer contact' })
|
||||
@ApiOkResponse({ type: CustomerDto })
|
||||
@ApiNotFoundResponse()
|
||||
@@ -147,7 +147,7 @@ export class CustomersWriteController {
|
||||
}
|
||||
|
||||
@Patch(':id/contacts/:contactId')
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update')
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update')
|
||||
@ApiOperation({ summary: 'Update a customer contact' })
|
||||
@ApiOkResponse({ type: CustomerDto })
|
||||
@ApiNotFoundResponse()
|
||||
@@ -167,7 +167,7 @@ export class CustomersWriteController {
|
||||
|
||||
@Delete(':id/contacts/:contactId')
|
||||
@HttpCode(204)
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update')
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update')
|
||||
@ApiOperation({ summary: 'Delete a customer contact' })
|
||||
@ApiNoContentResponse()
|
||||
@ApiNotFoundResponse()
|
||||
@@ -181,7 +181,7 @@ export class CustomersWriteController {
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update')
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update')
|
||||
@ApiOperation({ summary: 'Update customer status' })
|
||||
@ApiOkResponse({ type: CustomerDto })
|
||||
@ApiNotFoundResponse()
|
||||
@@ -196,7 +196,7 @@ export class CustomersWriteController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update')
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update')
|
||||
@ApiOperation({ summary: 'Update customer (not status)' })
|
||||
@ApiOkResponse({ type: CustomerDto })
|
||||
@ApiNotFoundResponse()
|
||||
@@ -215,7 +215,7 @@ export class CustomersWriteController {
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'delete')
|
||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'delete')
|
||||
@ApiOperation({ summary: 'Delete customer' })
|
||||
@ApiNoContentResponse()
|
||||
@ApiNotFoundResponse()
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { EmployeesModule } from '../employees/employees.module';
|
||||
import { TimelineModule } from '../../field/timeline/timeline.module';
|
||||
import { CustomersReadController } from './customers-read.controller';
|
||||
import { CustomersWriteController } from './customers-write.controller';
|
||||
import { CustomersRepository } from './customers.repository';
|
||||
import { CustomersService } from './customers.service';
|
||||
|
||||
@Module({
|
||||
imports: [EmployeesModule, TimelineModule],
|
||||
controllers: [CustomersReadController, CustomersWriteController],
|
||||
providers: [CustomersRepository, CustomersService],
|
||||
exports: [CustomersService],
|
||||
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
parseCsvRecord,
|
||||
} from './customer-fields';
|
||||
import { CustomersRepository } from './customers.repository';
|
||||
import { EmployeesService } from '../employees/employees.service';
|
||||
import { TimelineActivitiesService } from '../../field/timeline/timeline-activities.service';
|
||||
|
||||
export type ListCustomersQuery = {
|
||||
readonly code?: string;
|
||||
@@ -73,7 +75,11 @@ const CSV_REQUIRED_HEADERS = ['code', 'name', 'phone', 'address'] as const;
|
||||
|
||||
@Injectable()
|
||||
export class CustomersService {
|
||||
constructor(private readonly customersRepository: CustomersRepository) {}
|
||||
constructor(
|
||||
private readonly customersRepository: CustomersRepository,
|
||||
private readonly employeesService: EmployeesService,
|
||||
private readonly timelineActivitiesService: TimelineActivitiesService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
query: ListCustomersQuery,
|
||||
@@ -133,6 +139,16 @@ export class CustomersService {
|
||||
const created = await this.customersRepository.create(
|
||||
this.toCreateInput(input),
|
||||
);
|
||||
const employee = await this.employeesService.requireByUserId(input.userId);
|
||||
await this.timelineActivitiesService.recordIfLocated({
|
||||
employeeId: employee.id,
|
||||
type: 'customer_created',
|
||||
sourceType: 'customer',
|
||||
sourceId: created.id,
|
||||
latitude: created.latitude,
|
||||
longitude: created.longitude,
|
||||
customerId: created.id,
|
||||
});
|
||||
return this.toDetail(created);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import { DivisionDto, ListDivisionsQueryDto } from './dto/division.dto';
|
||||
import { DivisionsService } from './divisions.service';
|
||||
|
||||
export const DIVISION_PRIVILEGE_KEY = 'CONFIGURATION.DIVISION';
|
||||
export const DIVISION_PRIVILEGE_KEY = 'ADMIN.SETTINGS.DATA.DIVISION';
|
||||
|
||||
@ApiTags('divisions')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
|
||||
@@ -18,7 +18,7 @@ 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';
|
||||
export const EMPLOYEE_PRIVILEGE_KEY = 'ADMIN.SALES.DATA.EMPLOYEE';
|
||||
|
||||
@ApiTags('employees')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
|
||||
@@ -220,6 +220,26 @@ describe('EmployeesRepository', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('delete maps foreign-key violations to ConflictException', async () => {
|
||||
returning.mockRejectedValueOnce({ code: '23503' });
|
||||
await expect(repository.delete('emp-1')).rejects.toMatchObject({
|
||||
constructor: ConflictException,
|
||||
message: 'Employee is referenced by other records',
|
||||
});
|
||||
|
||||
returning.mockRejectedValueOnce({
|
||||
cause: { code: '23503' },
|
||||
});
|
||||
await expect(repository.delete('emp-1')).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('delete rethrows unknown errors', async () => {
|
||||
returning.mockRejectedValueOnce(new Error('db down'));
|
||||
await expect(repository.delete('emp-1')).rejects.toThrow('db down');
|
||||
});
|
||||
|
||||
it('bulkUpdateStatus and bulkDelete return 0 for empty ids', async () => {
|
||||
await expect(
|
||||
repository.bulkUpdateStatus([], Status.create('active'), 'user-1'),
|
||||
@@ -240,6 +260,16 @@ describe('EmployeesRepository', () => {
|
||||
await expect(repository.bulkDelete(['emp-1'])).resolves.toBe(1);
|
||||
});
|
||||
|
||||
it('bulkDelete maps foreign-key violations to ConflictException', async () => {
|
||||
returning.mockRejectedValueOnce({
|
||||
cause: { code: '23503' },
|
||||
});
|
||||
await expect(repository.bulkDelete(['emp-1'])).rejects.toMatchObject({
|
||||
constructor: ConflictException,
|
||||
message: 'Employee is referenced by other records',
|
||||
});
|
||||
});
|
||||
|
||||
it('extendListQuery is a passthrough hook', () => {
|
||||
const qb = { join: true };
|
||||
expect(repository.extendListQuery(qb, { limit: 10, offset: 0 })).toBe(qb);
|
||||
|
||||
@@ -221,12 +221,16 @@ export class EmployeesRepository {
|
||||
}
|
||||
|
||||
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');
|
||||
try {
|
||||
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');
|
||||
}
|
||||
} catch (error) {
|
||||
this.rethrowForeignKeyViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,11 +238,15 @@ export class EmployeesRepository {
|
||||
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;
|
||||
try {
|
||||
const deleted = await this.db
|
||||
.delete(employees)
|
||||
.where(inArray(employees.id, ids))
|
||||
.returning({ id: employees.id });
|
||||
return deleted.length;
|
||||
} catch (error) {
|
||||
this.rethrowForeignKeyViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
private buildListWhere(filters: ListEmployeesFilters): SQL | undefined {
|
||||
@@ -363,6 +371,14 @@ export class EmployeesRepository {
|
||||
throw error;
|
||||
}
|
||||
|
||||
private rethrowForeignKeyViolation(error: unknown): never {
|
||||
const err = this.unwrapDbError(error);
|
||||
if (err.code === '23503') {
|
||||
throw new ConflictException('Employee is referenced by other records');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
private unwrapDbError(error: unknown): {
|
||||
code?: string;
|
||||
constraint?: string;
|
||||
|
||||
@@ -117,6 +117,32 @@ export class EmployeesService {
|
||||
return this.toListItem(employee);
|
||||
}
|
||||
|
||||
async findRelationByUserId(userId: string): Promise<{
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
position: string;
|
||||
} | null> {
|
||||
const employee = await this.employeesRepository.findByUserId(userId);
|
||||
if (!employee) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: employee.id,
|
||||
code: employee.code,
|
||||
name: employee.name,
|
||||
position: employee.position,
|
||||
};
|
||||
}
|
||||
|
||||
async requireByUserId(userId: string): Promise<Employee> {
|
||||
const employee = await this.employeesRepository.findByUserId(userId);
|
||||
if (!employee) {
|
||||
throw new BadRequestException('User is not linked to an employee');
|
||||
}
|
||||
return employee;
|
||||
}
|
||||
|
||||
async create(input: {
|
||||
code: string;
|
||||
name: string;
|
||||
|
||||
@@ -18,7 +18,7 @@ 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';
|
||||
export const PRODUCT_PRIVILEGE_KEY = 'ADMIN.SETTINGS.DATA.PRODUCT';
|
||||
|
||||
@ApiTags('products')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import type { CheckInMethod } from '../shared/check-in-verification';
|
||||
|
||||
export type Attendance = {
|
||||
readonly id: string;
|
||||
readonly employeeId: string;
|
||||
readonly branchId: string;
|
||||
readonly date: DateTime;
|
||||
readonly checkInAt: DateTime;
|
||||
readonly checkInMethod: CheckInMethod;
|
||||
readonly checkInLatitude: number;
|
||||
readonly checkInLongitude: number;
|
||||
readonly checkInPhotoUrl: string | null;
|
||||
readonly checkInDistanceMeters: number | null;
|
||||
readonly checkOutAt: DateTime | null;
|
||||
readonly checkOutMethod: CheckInMethod | null;
|
||||
readonly checkOutLatitude: number | null;
|
||||
readonly checkOutLongitude: number | null;
|
||||
readonly checkOutPhotoUrl: string | null;
|
||||
readonly checkOutDistanceMeters: number | null;
|
||||
readonly status: Status;
|
||||
readonly createdAt: DateTime;
|
||||
readonly updatedAt: DateTime;
|
||||
readonly createdBy: string;
|
||||
readonly updatedBy: string;
|
||||
readonly employee: { id: string; code: string; name: string } | null;
|
||||
readonly branch: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
division: { id: string; code: string; name: string } | null;
|
||||
} | null;
|
||||
readonly createdByUser: { id: string; username: string } | null;
|
||||
readonly updatedByUser: { id: string; username: string } | null;
|
||||
};
|
||||
|
||||
export type ListAttendancesFilters = {
|
||||
readonly employeeId?: string;
|
||||
readonly branchId?: string;
|
||||
readonly date?: number;
|
||||
readonly status?: string;
|
||||
readonly search?: string;
|
||||
readonly orderBy?: string;
|
||||
readonly orderType?: string;
|
||||
readonly limit: number;
|
||||
readonly offset: number;
|
||||
};
|
||||
|
||||
export type CreateAttendanceInput = {
|
||||
readonly employeeId: string;
|
||||
readonly branchId: string;
|
||||
readonly date: DateTime;
|
||||
readonly checkInAt: DateTime;
|
||||
readonly checkInMethod: CheckInMethod;
|
||||
readonly checkInLatitude: number;
|
||||
readonly checkInLongitude: number;
|
||||
readonly checkInPhotoUrl: string | null;
|
||||
readonly checkInDistanceMeters: number | null;
|
||||
readonly userId: string;
|
||||
};
|
||||
|
||||
export type CheckOutAttendanceInput = {
|
||||
readonly checkOutAt: DateTime;
|
||||
readonly checkOutMethod: CheckInMethod;
|
||||
readonly checkOutLatitude: number;
|
||||
readonly checkOutLongitude: number;
|
||||
readonly checkOutPhotoUrl: string | null;
|
||||
readonly checkOutDistanceMeters: number | null;
|
||||
readonly userId: string;
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiForbiddenResponse,
|
||||
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 {
|
||||
Pagination,
|
||||
type PaginationResponse,
|
||||
PaginationMetaDto,
|
||||
} from '../../../common/http/response';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import { FIELD_ATTENDANCE_PRIVILEGE_KEY } from '../shared/field-purpose';
|
||||
import { AttendanceDto, ListAttendancesQueryDto } from './dto/attendance.dto';
|
||||
import { AttendancesService } from './attendances.service';
|
||||
|
||||
@ApiTags('attendances')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('attendances')
|
||||
export class AttendancesReadController {
|
||||
constructor(private readonly attendancesService: AttendancesService) {}
|
||||
|
||||
@Get('current')
|
||||
@RequirePrivilege(FIELD_ATTENDANCE_PRIVILEGE_KEY, 'view')
|
||||
@ApiOperation({ summary: 'Get the open attendance for the current user' })
|
||||
@ApiOkResponse({
|
||||
type: AttendanceDto,
|
||||
description: 'Null when no open shift',
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
current(@CurrentUser('id') userId: string): Promise<AttendanceDto | null> {
|
||||
return this.attendancesService.findCurrent(userId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Pagination()
|
||||
@RequirePrivilege(FIELD_ATTENDANCE_PRIVILEGE_KEY, 'view')
|
||||
@ApiOperation({ summary: 'List attendances' })
|
||||
@ApiOkResponse({
|
||||
schema: {
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/AttendanceDto' },
|
||||
},
|
||||
meta: { $ref: '#/components/schemas/PaginationMetaDto' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
list(
|
||||
@Query() query: ListAttendancesQueryDto,
|
||||
): Promise<PaginationResponse<AttendanceDto>> {
|
||||
return this.attendancesService.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePrivilege(FIELD_ATTENDANCE_PRIVILEGE_KEY, 'view')
|
||||
@ApiOperation({ summary: 'Get attendance detail' })
|
||||
@ApiOkResponse({ type: AttendanceDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<AttendanceDto> {
|
||||
return this.attendancesService.findById(id);
|
||||
}
|
||||
}
|
||||
|
||||
void PaginationMetaDto;
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
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 { FIELD_ATTENDANCE_PRIVILEGE_KEY } from '../shared/field-purpose';
|
||||
import {
|
||||
AttendanceCheckInDto,
|
||||
AttendanceCheckOutDto,
|
||||
AttendanceDto,
|
||||
BulkIdsDto,
|
||||
BulkStatusDto,
|
||||
UpdateAttendanceStatusDto,
|
||||
} from './dto/attendance.dto';
|
||||
import { AttendancesService } from './attendances.service';
|
||||
|
||||
@ApiTags('attendances')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('attendances')
|
||||
export class AttendancesWriteController {
|
||||
constructor(private readonly attendancesService: AttendancesService) {}
|
||||
|
||||
@Post('check-in')
|
||||
@RequirePrivilege(FIELD_ATTENDANCE_PRIVILEGE_KEY, 'create')
|
||||
@ApiOperation({ summary: 'Check in at a branch' })
|
||||
@ApiCreatedResponse({ type: AttendanceDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
checkIn(
|
||||
@Body() dto: AttendanceCheckInDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<AttendanceDto> {
|
||||
return this.attendancesService.checkIn(dto, userId);
|
||||
}
|
||||
|
||||
@Post('bulk-delete')
|
||||
@HttpCode(200)
|
||||
@RequirePrivilege(FIELD_ATTENDANCE_PRIVILEGE_KEY, 'delete')
|
||||
@ApiOperation({ summary: 'Bulk delete attendances' })
|
||||
@ApiOkResponse({ schema: { properties: { deleted: { type: 'number' } } } })
|
||||
bulkDelete(@Body() dto: BulkIdsDto): Promise<{ deleted: number }> {
|
||||
return this.attendancesService.bulkDelete(dto.ids);
|
||||
}
|
||||
|
||||
@Post('bulk-status')
|
||||
@HttpCode(200)
|
||||
@RequirePrivilege(FIELD_ATTENDANCE_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Bulk update attendance status' })
|
||||
@ApiOkResponse({ schema: { properties: { updated: { type: 'number' } } } })
|
||||
bulkStatus(
|
||||
@Body() dto: BulkStatusDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<{ updated: number }> {
|
||||
return this.attendancesService.bulkUpdateStatus(
|
||||
dto.ids,
|
||||
dto.status,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/check-out')
|
||||
@RequirePrivilege(FIELD_ATTENDANCE_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Check out from a branch shift' })
|
||||
@ApiOkResponse({ type: AttendanceDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
checkOut(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: AttendanceCheckOutDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<AttendanceDto> {
|
||||
return this.attendancesService.checkOut(id, dto, userId);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@RequirePrivilege(FIELD_ATTENDANCE_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Update attendance status' })
|
||||
@ApiOkResponse({ type: AttendanceDto })
|
||||
updateStatus(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateAttendanceStatusDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<AttendanceDto> {
|
||||
return this.attendancesService.updateStatus(id, dto.status, userId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@RequirePrivilege(FIELD_ATTENDANCE_PRIVILEGE_KEY, 'delete')
|
||||
@ApiOperation({ summary: 'Delete attendance' })
|
||||
@ApiNoContentResponse()
|
||||
@ApiNotFoundResponse()
|
||||
delete(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||
return this.attendancesService.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { and, count, eq, ilike, inArray, isNull, or, SQL } from 'drizzle-orm';
|
||||
import { alias } from 'drizzle-orm/pg-core';
|
||||
import { toOrderClauses } from '../../../common/http/response/order-clause';
|
||||
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 {
|
||||
attendances,
|
||||
type AttendanceRow,
|
||||
} from '../../../database/attendances-table';
|
||||
import { branches } from '../../../database/branches-table';
|
||||
import { divisions, employees, users } from '../../../database/schema';
|
||||
import type {
|
||||
Attendance,
|
||||
CheckOutAttendanceInput,
|
||||
CreateAttendanceInput,
|
||||
ListAttendancesFilters,
|
||||
} from './attendance';
|
||||
import type { CheckInMethod } from '../shared/check-in-verification';
|
||||
|
||||
const ATTENDANCE_ORDER_COLUMNS = {
|
||||
id: attendances.id,
|
||||
date: attendances.date,
|
||||
status: attendances.status,
|
||||
createdAt: attendances.createdAt,
|
||||
updatedAt: attendances.updatedAt,
|
||||
};
|
||||
|
||||
const createdByUsers = alias(users, 'attendance_created_by_users');
|
||||
const updatedByUsers = alias(users, 'attendance_updated_by_users');
|
||||
|
||||
type AttendanceJoinedRow = {
|
||||
attendance: AttendanceRow;
|
||||
employee: typeof employees.$inferSelect;
|
||||
branch: typeof branches.$inferSelect;
|
||||
division: typeof divisions.$inferSelect | null;
|
||||
createdByUser: typeof users.$inferSelect | null;
|
||||
updatedByUser: typeof users.$inferSelect | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AttendancesRepository {
|
||||
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||
|
||||
async list(
|
||||
filters: ListAttendancesFilters,
|
||||
): Promise<{ data: Attendance[]; total: number }> {
|
||||
const where = this.buildListWhere(filters);
|
||||
const totalRows = await this.db
|
||||
.select({ total: count() })
|
||||
.from(attendances)
|
||||
.where(where);
|
||||
const totalRow = totalRows[0];
|
||||
|
||||
const rows = await this.selectWithRelations()
|
||||
.where(where)
|
||||
.orderBy(
|
||||
...toOrderClauses(ATTENDANCE_ORDER_COLUMNS, filters, [
|
||||
{ column: 'date', type: 'DESC' },
|
||||
]),
|
||||
)
|
||||
.limit(filters.limit)
|
||||
.offset(filters.offset);
|
||||
|
||||
return {
|
||||
data: rows.map((row) => this.toDomain(row)),
|
||||
total: Number(totalRow?.total ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Attendance | null> {
|
||||
const rows = await this.selectWithRelations()
|
||||
.where(eq(attendances.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
|
||||
async findOpenByEmployeeId(employeeId: string): Promise<Attendance | null> {
|
||||
const rows = await this.selectWithRelations()
|
||||
.where(
|
||||
and(
|
||||
eq(attendances.employeeId, employeeId),
|
||||
isNull(attendances.checkOutAt),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
|
||||
async create(input: CreateAttendanceInput): Promise<Attendance> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
try {
|
||||
const inserted = await this.db
|
||||
.insert(attendances)
|
||||
.values({
|
||||
employeeId: input.employeeId,
|
||||
branchId: input.branchId,
|
||||
date: input.date.value,
|
||||
checkInAt: input.checkInAt.value,
|
||||
checkInMethod: input.checkInMethod,
|
||||
checkInLatitude: input.checkInLatitude,
|
||||
checkInLongitude: input.checkInLongitude,
|
||||
checkInPhotoUrl: input.checkInPhotoUrl,
|
||||
checkInDistanceMeters: input.checkInDistanceMeters,
|
||||
status: Status.create('active').value,
|
||||
createdAt: now.value,
|
||||
updatedAt: now.value,
|
||||
createdBy: input.userId,
|
||||
updatedBy: input.userId,
|
||||
})
|
||||
.returning();
|
||||
const created = await this.findById(inserted[0].id);
|
||||
if (!created) {
|
||||
throw new NotFoundException('Attendance not found');
|
||||
}
|
||||
return created;
|
||||
} catch (error) {
|
||||
this.rethrowUniqueViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async checkOut(
|
||||
id: string,
|
||||
input: CheckOutAttendanceInput,
|
||||
): Promise<Attendance> {
|
||||
const existing = await this.findById(id);
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Attendance not found');
|
||||
}
|
||||
if (existing.checkOutAt) {
|
||||
throw new ConflictException('Attendance is already checked out');
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
await this.db
|
||||
.update(attendances)
|
||||
.set({
|
||||
checkOutAt: input.checkOutAt.value,
|
||||
checkOutMethod: input.checkOutMethod,
|
||||
checkOutLatitude: input.checkOutLatitude,
|
||||
checkOutLongitude: input.checkOutLongitude,
|
||||
checkOutPhotoUrl: input.checkOutPhotoUrl,
|
||||
checkOutDistanceMeters: input.checkOutDistanceMeters,
|
||||
updatedAt: now.value,
|
||||
updatedBy: input.userId,
|
||||
})
|
||||
.where(eq(attendances.id, id));
|
||||
const updated = await this.findById(id);
|
||||
if (!updated) {
|
||||
throw new NotFoundException('Attendance not found');
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
status: Status,
|
||||
userId: string,
|
||||
): Promise<Attendance> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const updated = await this.db
|
||||
.update(attendances)
|
||||
.set({
|
||||
status: status.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(eq(attendances.id, id))
|
||||
.returning({ id: attendances.id });
|
||||
if (updated.length === 0) {
|
||||
throw new NotFoundException('Attendance not found');
|
||||
}
|
||||
const row = await this.findById(id);
|
||||
if (!row) {
|
||||
throw new NotFoundException('Attendance not found');
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
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(attendances)
|
||||
.set({
|
||||
status: status.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(inArray(attendances.id, ids))
|
||||
.returning({ id: attendances.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const deleted = await this.db
|
||||
.delete(attendances)
|
||||
.where(eq(attendances.id, id))
|
||||
.returning({ id: attendances.id });
|
||||
if (deleted.length === 0) {
|
||||
throw new NotFoundException('Attendance not found');
|
||||
}
|
||||
}
|
||||
|
||||
async bulkDelete(ids: string[]): Promise<number> {
|
||||
if (ids.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const deleted = await this.db
|
||||
.delete(attendances)
|
||||
.where(inArray(attendances.id, ids))
|
||||
.returning({ id: attendances.id });
|
||||
return deleted.length;
|
||||
}
|
||||
|
||||
private selectWithRelations() {
|
||||
return this.db
|
||||
.select({
|
||||
attendance: attendances,
|
||||
employee: employees,
|
||||
branch: branches,
|
||||
division: divisions,
|
||||
createdByUser: createdByUsers,
|
||||
updatedByUser: updatedByUsers,
|
||||
})
|
||||
.from(attendances)
|
||||
.innerJoin(employees, eq(attendances.employeeId, employees.id))
|
||||
.innerJoin(branches, eq(attendances.branchId, branches.id))
|
||||
.leftJoin(divisions, eq(branches.divisionId, divisions.id))
|
||||
.leftJoin(createdByUsers, eq(attendances.createdBy, createdByUsers.id))
|
||||
.leftJoin(updatedByUsers, eq(attendances.updatedBy, updatedByUsers.id));
|
||||
}
|
||||
|
||||
private buildListWhere(filters: ListAttendancesFilters): SQL | undefined {
|
||||
const parts: SQL[] = [];
|
||||
if (filters.employeeId) {
|
||||
parts.push(eq(attendances.employeeId, filters.employeeId));
|
||||
}
|
||||
if (filters.branchId) {
|
||||
parts.push(eq(attendances.branchId, filters.branchId));
|
||||
}
|
||||
if (filters.date !== undefined) {
|
||||
parts.push(eq(attendances.date, filters.date));
|
||||
}
|
||||
if (filters.status) {
|
||||
parts.push(eq(attendances.status, filters.status));
|
||||
}
|
||||
if (filters.search) {
|
||||
const search = or(
|
||||
ilike(employees.code, `%${filters.search}%`),
|
||||
ilike(employees.name, `%${filters.search}%`),
|
||||
ilike(branches.code, `%${filters.search}%`),
|
||||
ilike(branches.name, `%${filters.search}%`),
|
||||
);
|
||||
if (search) {
|
||||
parts.push(search);
|
||||
}
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return parts.length === 1 ? parts[0] : and(...parts);
|
||||
}
|
||||
|
||||
private toDomain(row: AttendanceJoinedRow): Attendance {
|
||||
const attendance = row.attendance;
|
||||
return {
|
||||
id: attendance.id,
|
||||
employeeId: attendance.employeeId,
|
||||
branchId: attendance.branchId,
|
||||
date: DateTime.fromUnixMs(attendance.date),
|
||||
checkInAt: DateTime.fromUnixMs(attendance.checkInAt),
|
||||
checkInMethod: attendance.checkInMethod as CheckInMethod,
|
||||
checkInLatitude: attendance.checkInLatitude,
|
||||
checkInLongitude: attendance.checkInLongitude,
|
||||
checkInPhotoUrl: attendance.checkInPhotoUrl,
|
||||
checkInDistanceMeters: attendance.checkInDistanceMeters,
|
||||
checkOutAt: attendance.checkOutAt
|
||||
? DateTime.fromUnixMs(attendance.checkOutAt)
|
||||
: null,
|
||||
checkOutMethod: attendance.checkOutMethod as CheckInMethod | null,
|
||||
checkOutLatitude: attendance.checkOutLatitude,
|
||||
checkOutLongitude: attendance.checkOutLongitude,
|
||||
checkOutPhotoUrl: attendance.checkOutPhotoUrl,
|
||||
checkOutDistanceMeters: attendance.checkOutDistanceMeters,
|
||||
status: Status.create(attendance.status),
|
||||
createdAt: DateTime.fromUnixMs(attendance.createdAt),
|
||||
updatedAt: DateTime.fromUnixMs(attendance.updatedAt),
|
||||
createdBy: attendance.createdBy,
|
||||
updatedBy: attendance.updatedBy,
|
||||
employee: {
|
||||
id: row.employee.id,
|
||||
code: row.employee.code,
|
||||
name: row.employee.name,
|
||||
},
|
||||
branch: {
|
||||
id: row.branch.id,
|
||||
code: row.branch.code,
|
||||
name: row.branch.name,
|
||||
division: row.division
|
||||
? {
|
||||
id: row.division.id,
|
||||
code: row.division.code,
|
||||
name: row.division.name,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
createdByUser: row.createdByUser
|
||||
? { id: row.createdByUser.id, username: row.createdByUser.username }
|
||||
: null,
|
||||
updatedByUser: row.updatedByUser
|
||||
? { id: row.updatedByUser.id, username: row.updatedByUser.username }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
private rethrowUniqueViolation(error: unknown): never {
|
||||
let current: unknown = error;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (!current || typeof current !== 'object') {
|
||||
break;
|
||||
}
|
||||
const obj = current as { code?: string; cause?: unknown };
|
||||
if (obj.code === '23505') {
|
||||
throw new ConflictException('Attendance already exists for this shift');
|
||||
}
|
||||
current = obj.cause;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { PaginationResponse } from '../../../common/http/response';
|
||||
import {
|
||||
pickRelation,
|
||||
pickUserRelation,
|
||||
DEFAULT_RELATION_FIELDS,
|
||||
USER_RELATION_FIELDS,
|
||||
toListPage,
|
||||
} from '../../../common/http/response';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { BranchesService } from '../../configuration/branches/branches.service';
|
||||
import { EmployeesService } from '../../configuration/employees/employees.service';
|
||||
import { CompanySettingsService } from '../settings/company-settings.service';
|
||||
import {
|
||||
isCheckInMethod,
|
||||
verifyBranchCheckIn,
|
||||
type CheckInPayload,
|
||||
type CheckInVerificationOptions,
|
||||
} from '../shared/check-in-verification';
|
||||
import { FIELD_ATTENDANCE_PRIVILEGE_KEY } from '../shared/field-purpose';
|
||||
import { TimelineActivitiesService } from '../timeline/timeline-activities.service';
|
||||
import type { Attendance } from './attendance';
|
||||
import type {
|
||||
AttendanceCheckInDto,
|
||||
AttendanceCheckOutDto,
|
||||
AttendanceDto,
|
||||
ListAttendancesQueryDto,
|
||||
} from './dto/attendance.dto';
|
||||
import { AttendancesRepository } from './attendances.repository';
|
||||
|
||||
@Injectable()
|
||||
export class AttendancesService {
|
||||
constructor(
|
||||
private readonly attendancesRepository: AttendancesRepository,
|
||||
private readonly branchesService: BranchesService,
|
||||
private readonly employeesService: EmployeesService,
|
||||
private readonly companySettingsService: CompanySettingsService,
|
||||
private readonly timelineActivitiesService: TimelineActivitiesService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
query: ListAttendancesQueryDto,
|
||||
): Promise<PaginationResponse<AttendanceDto>> {
|
||||
const page = toListPage(query);
|
||||
const { data, total } = await this.attendancesRepository.list({
|
||||
employeeId: query.employeeId,
|
||||
branchId: query.branchId,
|
||||
date: query.date,
|
||||
status: query.status,
|
||||
search: query.search,
|
||||
orderBy: query.orderBy,
|
||||
orderType: query.orderType,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
});
|
||||
return {
|
||||
data: data.map((item) => this.toItem(item)),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<AttendanceDto> {
|
||||
const attendance = await this.attendancesRepository.findById(id);
|
||||
if (!attendance) {
|
||||
throw new NotFoundException('Attendance not found');
|
||||
}
|
||||
return this.toItem(attendance);
|
||||
}
|
||||
|
||||
async findCurrent(userId: string): Promise<AttendanceDto | null> {
|
||||
const employee = await this.employeesService.requireByUserId(userId);
|
||||
const attendance = await this.attendancesRepository.findOpenByEmployeeId(
|
||||
employee.id,
|
||||
);
|
||||
return attendance ? this.toItem(attendance) : null;
|
||||
}
|
||||
|
||||
async checkIn(
|
||||
dto: AttendanceCheckInDto,
|
||||
userId: string,
|
||||
): Promise<AttendanceDto> {
|
||||
const employee = await this.employeesService.requireByUserId(userId);
|
||||
const open = await this.attendancesRepository.findOpenByEmployeeId(
|
||||
employee.id,
|
||||
);
|
||||
if (open) {
|
||||
throw new ConflictException('An attendance shift is already open');
|
||||
}
|
||||
|
||||
const branch = await this.branchesService.findById(dto.branchId);
|
||||
|
||||
const radiusMeters =
|
||||
await this.companySettingsService.requireCheckInRadiusMeters();
|
||||
const payload = this.toPayload(dto);
|
||||
const verified = verifyBranchCheckIn(
|
||||
{
|
||||
code: branch.code,
|
||||
nfcId: branch.nfcId,
|
||||
latitude: branch.latitude,
|
||||
longitude: branch.longitude,
|
||||
},
|
||||
payload,
|
||||
radiusMeters,
|
||||
this.gpsVerificationOptions(),
|
||||
);
|
||||
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const created = await this.attendancesRepository.create({
|
||||
employeeId: employee.id,
|
||||
branchId: branch.id,
|
||||
date: now.startOfDay(),
|
||||
checkInAt: now,
|
||||
checkInMethod: verified.method,
|
||||
checkInLatitude: verified.latitude,
|
||||
checkInLongitude: verified.longitude,
|
||||
checkInPhotoUrl: verified.photoUrl,
|
||||
checkInDistanceMeters: verified.distanceMeters,
|
||||
userId,
|
||||
});
|
||||
await this.timelineActivitiesService.recordIfLocated({
|
||||
employeeId: employee.id,
|
||||
type: 'branch_check_in',
|
||||
sourceType: 'attendance',
|
||||
sourceId: created.id,
|
||||
latitude: verified.latitude,
|
||||
longitude: verified.longitude,
|
||||
recordedAt: now.value,
|
||||
});
|
||||
return this.toItem(created);
|
||||
}
|
||||
|
||||
async checkOut(
|
||||
id: string,
|
||||
dto: AttendanceCheckOutDto,
|
||||
userId: string,
|
||||
): Promise<AttendanceDto> {
|
||||
const employee = await this.employeesService.requireByUserId(userId);
|
||||
const attendance = await this.attendancesRepository.findById(id);
|
||||
if (!attendance) {
|
||||
throw new NotFoundException('Attendance not found');
|
||||
}
|
||||
if (attendance.employeeId !== employee.id) {
|
||||
throw new BadRequestException('Attendance does not belong to this user');
|
||||
}
|
||||
if (attendance.checkOutAt) {
|
||||
throw new ConflictException('Attendance is already checked out');
|
||||
}
|
||||
|
||||
const branch = await this.branchesService.findById(attendance.branchId);
|
||||
|
||||
const radiusMeters =
|
||||
await this.companySettingsService.requireCheckInRadiusMeters();
|
||||
const payload = this.toPayload(dto);
|
||||
const verified = verifyBranchCheckIn(
|
||||
{
|
||||
code: branch.code,
|
||||
nfcId: branch.nfcId,
|
||||
latitude: branch.latitude,
|
||||
longitude: branch.longitude,
|
||||
},
|
||||
payload,
|
||||
radiusMeters,
|
||||
this.gpsVerificationOptions(),
|
||||
);
|
||||
|
||||
const updated = await this.attendancesRepository.checkOut(id, {
|
||||
checkOutAt: DateTime.fromUnixMs(Date.now()),
|
||||
checkOutMethod: verified.method,
|
||||
checkOutLatitude: verified.latitude,
|
||||
checkOutLongitude: verified.longitude,
|
||||
checkOutPhotoUrl: verified.photoUrl,
|
||||
checkOutDistanceMeters: verified.distanceMeters,
|
||||
userId,
|
||||
});
|
||||
await this.timelineActivitiesService.recordIfLocated({
|
||||
employeeId: employee.id,
|
||||
type: 'branch_check_out',
|
||||
sourceType: 'attendance',
|
||||
sourceId: updated.id,
|
||||
latitude: verified.latitude,
|
||||
longitude: verified.longitude,
|
||||
recordedAt: updated.checkOutAt?.value,
|
||||
});
|
||||
return this.toItem(updated);
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
statusRaw: string,
|
||||
userId: string,
|
||||
): Promise<AttendanceDto> {
|
||||
const status = Status.create(statusRaw);
|
||||
const updated = await this.attendancesRepository.updateStatus(
|
||||
id,
|
||||
status,
|
||||
userId,
|
||||
);
|
||||
return this.toItem(updated);
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
ids: string[],
|
||||
statusRaw: string,
|
||||
userId: string,
|
||||
): Promise<{ updated: number }> {
|
||||
const status = Status.create(statusRaw);
|
||||
const updated = await this.attendancesRepository.bulkUpdateStatus(
|
||||
ids,
|
||||
status,
|
||||
userId,
|
||||
);
|
||||
return { updated };
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.attendancesRepository.delete(id);
|
||||
}
|
||||
|
||||
async bulkDelete(ids: string[]): Promise<{ deleted: number }> {
|
||||
const deleted = await this.attendancesRepository.bulkDelete(ids);
|
||||
return { deleted };
|
||||
}
|
||||
|
||||
toItem(attendance: Attendance): AttendanceDto {
|
||||
return {
|
||||
id: attendance.id,
|
||||
employee: pickRelation(attendance.employee, DEFAULT_RELATION_FIELDS)!,
|
||||
branch: pickRelation(attendance.branch, DEFAULT_RELATION_FIELDS)!,
|
||||
date: attendance.date.value,
|
||||
checkInAt: attendance.checkInAt.value,
|
||||
checkInMethod: attendance.checkInMethod,
|
||||
checkInLatitude: attendance.checkInLatitude,
|
||||
checkInLongitude: attendance.checkInLongitude,
|
||||
checkInPhotoUrl: attendance.checkInPhotoUrl,
|
||||
checkInDistanceMeters: attendance.checkInDistanceMeters,
|
||||
checkOutAt: attendance.checkOutAt?.value ?? null,
|
||||
checkOutMethod: attendance.checkOutMethod,
|
||||
checkOutLatitude: attendance.checkOutLatitude,
|
||||
checkOutLongitude: attendance.checkOutLongitude,
|
||||
checkOutPhotoUrl: attendance.checkOutPhotoUrl,
|
||||
checkOutDistanceMeters: attendance.checkOutDistanceMeters,
|
||||
status: attendance.status.value,
|
||||
createdAt: attendance.createdAt.value,
|
||||
updatedAt: attendance.updatedAt.value,
|
||||
createdBy: pickUserRelation(
|
||||
attendance.createdByUser ?? {
|
||||
id: attendance.createdBy,
|
||||
username: '',
|
||||
},
|
||||
),
|
||||
updatedBy: pickUserRelation(
|
||||
attendance.updatedByUser ?? {
|
||||
id: attendance.updatedBy,
|
||||
username: '',
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
static privilegeKey(): string {
|
||||
return FIELD_ATTENDANCE_PRIVILEGE_KEY;
|
||||
}
|
||||
|
||||
private gpsVerificationOptions(): CheckInVerificationOptions {
|
||||
return {
|
||||
skipGpsValidation:
|
||||
this.config.get<boolean>('SKIP_GPS_VALIDATION') === true,
|
||||
};
|
||||
}
|
||||
|
||||
private toPayload(
|
||||
dto: AttendanceCheckInDto | AttendanceCheckOutDto,
|
||||
): CheckInPayload {
|
||||
if (!isCheckInMethod(dto.method)) {
|
||||
throw new BadRequestException('Invalid check-in method');
|
||||
}
|
||||
return {
|
||||
method: dto.method,
|
||||
nfcId: dto.nfcId,
|
||||
qrCode: dto.qrCode,
|
||||
latitude: dto.latitude,
|
||||
longitude: dto.longitude,
|
||||
photoUrl: dto.photoUrl,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsLatitude,
|
||||
IsLongitude,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
IsUUID,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import { CORE_STATUSES } from '../../../../common/value-objects/status/status';
|
||||
import { PaginationQueryDto } from '../../../../common/http/response';
|
||||
import { CHECK_IN_METHODS } from '../../shared/check-in-verification';
|
||||
|
||||
export class AttendanceCheckInDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID('4')
|
||||
branchId!: string;
|
||||
|
||||
@ApiProperty({ enum: CHECK_IN_METHODS })
|
||||
@IsIn([...CHECK_IN_METHODS])
|
||||
method!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@ValidateIf((dto: AttendanceCheckInDto) => dto.method === 'nfc')
|
||||
@IsString()
|
||||
nfcId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@ValidateIf((dto: AttendanceCheckInDto) => dto.method === 'qr')
|
||||
@IsString()
|
||||
qrCode?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsLatitude()
|
||||
latitude!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsLongitude()
|
||||
longitude!: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsUrl({ require_protocol: true, protocols: ['https'] })
|
||||
photoUrl?: string;
|
||||
}
|
||||
|
||||
export class AttendanceCheckOutDto {
|
||||
@ApiProperty({ enum: CHECK_IN_METHODS })
|
||||
@IsIn([...CHECK_IN_METHODS])
|
||||
method!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@ValidateIf((dto: AttendanceCheckOutDto) => dto.method === 'nfc')
|
||||
@IsString()
|
||||
nfcId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@ValidateIf((dto: AttendanceCheckOutDto) => dto.method === 'qr')
|
||||
@IsString()
|
||||
qrCode?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsLatitude()
|
||||
latitude!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsLongitude()
|
||||
longitude!: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsUrl({ require_protocol: true, protocols: ['https'] })
|
||||
photoUrl?: string;
|
||||
}
|
||||
|
||||
export class UpdateAttendanceStatusDto {
|
||||
@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 ListAttendancesQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
employeeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
branchId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Unix ms start of calendar day' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
date?: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: CORE_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...CORE_STATUSES])
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
}
|
||||
|
||||
class RelationDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
code!: string;
|
||||
|
||||
@ApiProperty()
|
||||
name!: string;
|
||||
}
|
||||
|
||||
class UserRelationDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
username!: string;
|
||||
}
|
||||
|
||||
export class AttendanceDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ type: RelationDto })
|
||||
employee!: RelationDto;
|
||||
|
||||
@ApiProperty({ type: RelationDto })
|
||||
branch!: RelationDto;
|
||||
|
||||
@ApiProperty()
|
||||
date!: number;
|
||||
|
||||
@ApiProperty()
|
||||
checkInAt!: number;
|
||||
|
||||
@ApiProperty({ enum: CHECK_IN_METHODS })
|
||||
checkInMethod!: string;
|
||||
|
||||
@ApiProperty()
|
||||
checkInLatitude!: number;
|
||||
|
||||
@ApiProperty()
|
||||
checkInLongitude!: number;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
checkInPhotoUrl!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
checkInDistanceMeters!: number | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
checkOutAt!: number | null;
|
||||
|
||||
@ApiProperty({ enum: CHECK_IN_METHODS, nullable: true })
|
||||
checkOutMethod!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
checkOutLatitude!: number | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
checkOutLongitude!: number | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
checkOutPhotoUrl!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
checkOutDistanceMeters!: number | null;
|
||||
|
||||
@ApiProperty()
|
||||
status!: string;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt!: number;
|
||||
|
||||
@ApiProperty()
|
||||
updatedAt!: number;
|
||||
|
||||
@ApiProperty({ type: UserRelationDto })
|
||||
createdBy!: UserRelationDto;
|
||||
|
||||
@ApiProperty({ type: UserRelationDto })
|
||||
updatedBy!: UserRelationDto;
|
||||
}
|
||||
@@ -34,7 +34,7 @@ describe('CyclesService', () => {
|
||||
const employeesService = { findById: jest.fn(), findByCode: jest.fn() };
|
||||
const branchesService = { findById: jest.fn(), findByCode: jest.fn() };
|
||||
const customersService = { findById: jest.fn(), findByCode: jest.fn() };
|
||||
const privilegesService = { checkPermission: jest.fn() };
|
||||
const privilegesService = { checkPermission: jest.fn(), checkAnyPermission: jest.fn() };
|
||||
|
||||
const now = DateTime.fromUnixMs(1_700_000_000_000);
|
||||
const user: AuthUser = {
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
type WeekdaysInput,
|
||||
} from '../shared/field-fields';
|
||||
import {
|
||||
fieldPrivilegeKey,
|
||||
fieldPrivilegeKeys,
|
||||
isFieldPurpose,
|
||||
WEEKDAY_NAMES,
|
||||
type FieldPurpose,
|
||||
@@ -485,9 +485,9 @@ export class CyclesService {
|
||||
}
|
||||
const allowed: FieldPurpose[] = [];
|
||||
for (const purpose of ['sales', 'logistics'] as const) {
|
||||
const ok = await this.privilegesService.checkPermission(
|
||||
const ok = await this.privilegesService.checkAnyPermission(
|
||||
user.id,
|
||||
fieldPrivilegeKey('cycle', purpose),
|
||||
fieldPrivilegeKeys('cycle', purpose),
|
||||
action,
|
||||
);
|
||||
if (ok) {
|
||||
@@ -505,9 +505,9 @@ export class CyclesService {
|
||||
if (user.isSuperadmin) {
|
||||
return;
|
||||
}
|
||||
const ok = await this.privilegesService.checkPermission(
|
||||
const ok = await this.privilegesService.checkAnyPermission(
|
||||
user.id,
|
||||
fieldPrivilegeKey('cycle', purpose),
|
||||
fieldPrivilegeKeys('cycle', purpose),
|
||||
action,
|
||||
);
|
||||
if (!ok) {
|
||||
|
||||
@@ -5,6 +5,10 @@ import { EmployeesModule } from '../configuration/employees/employees.module';
|
||||
import { PrivilegesModule } from '../privileges/privileges.module';
|
||||
import { PackingSlipsModule } from '../sales/packing-slips/packing-slips.module';
|
||||
import { SalesInvoicesModule } from '../sales/sales-invoices/sales-invoices.module';
|
||||
import { AttendancesReadController } from './attendances/attendances-read.controller';
|
||||
import { AttendancesWriteController } from './attendances/attendances-write.controller';
|
||||
import { AttendancesRepository } from './attendances/attendances.repository';
|
||||
import { AttendancesService } from './attendances/attendances.service';
|
||||
import { CyclesReadController } from './cycles/cycles-read.controller';
|
||||
import { CyclesWriteController } from './cycles/cycles-write.controller';
|
||||
import { CyclesRepository } from './cycles/cycles.repository';
|
||||
@@ -16,7 +20,12 @@ import { PlansService } from './plans/plans.service';
|
||||
import { CompanySettingsController } from './settings/company-settings.controller';
|
||||
import { CompanySettingsRepository } from './settings/company-settings.repository';
|
||||
import { CompanySettingsService } from './settings/company-settings.service';
|
||||
import { VisitsReadController } from './visits/visits-read.controller';
|
||||
import { VisitsWriteController } from './visits/visits-write.controller';
|
||||
import { VisitsRepository } from './visits/visits.repository';
|
||||
import { VisitsService } from './visits/visits.service';
|
||||
import { FieldPrivilegeGuard } from './shared/field-privilege.guard';
|
||||
import { TimelineModule } from './timeline/timeline.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -26,23 +35,39 @@ import { FieldPrivilegeGuard } from './shared/field-privilege.guard';
|
||||
CustomersModule,
|
||||
SalesInvoicesModule,
|
||||
PackingSlipsModule,
|
||||
TimelineModule,
|
||||
],
|
||||
controllers: [
|
||||
CompanySettingsController,
|
||||
AttendancesReadController,
|
||||
AttendancesWriteController,
|
||||
CyclesReadController,
|
||||
CyclesWriteController,
|
||||
PlansReadController,
|
||||
PlansWriteController,
|
||||
VisitsReadController,
|
||||
VisitsWriteController,
|
||||
],
|
||||
providers: [
|
||||
FieldPrivilegeGuard,
|
||||
CompanySettingsRepository,
|
||||
CompanySettingsService,
|
||||
AttendancesRepository,
|
||||
AttendancesService,
|
||||
CyclesRepository,
|
||||
CyclesService,
|
||||
PlansRepository,
|
||||
PlansService,
|
||||
VisitsRepository,
|
||||
VisitsService,
|
||||
],
|
||||
exports: [
|
||||
CompanySettingsService,
|
||||
AttendancesService,
|
||||
CyclesService,
|
||||
PlansService,
|
||||
VisitsService,
|
||||
TimelineModule,
|
||||
],
|
||||
exports: [CompanySettingsService, CyclesService, PlansService],
|
||||
})
|
||||
export class FieldModule {}
|
||||
|
||||
@@ -79,9 +79,7 @@ export class PlansRepository {
|
||||
.limit(filters.limit)
|
||||
.offset(filters.offset);
|
||||
return {
|
||||
data: await this.hydrate(
|
||||
rows.map((row) => this.toDomain(row, [], [], [])),
|
||||
),
|
||||
data: await this.loadChildrenForRows(this.db, rows),
|
||||
total: Number(totalRows[0]?.total ?? 0),
|
||||
};
|
||||
}
|
||||
@@ -340,20 +338,61 @@ export class PlansRepository {
|
||||
executor: QueryExecutor,
|
||||
row: PlanRow,
|
||||
): Promise<Plan> {
|
||||
const destinations = await executor
|
||||
.select()
|
||||
.from(planDestinations)
|
||||
.where(eq(planDestinations.planId, row.id))
|
||||
.orderBy(asc(planDestinations.sortOrder));
|
||||
const invoices = await executor
|
||||
.select()
|
||||
.from(planInvoices)
|
||||
.where(eq(planInvoices.planId, row.id));
|
||||
const packingSlips = await executor
|
||||
.select()
|
||||
.from(planPackingSlips)
|
||||
.where(eq(planPackingSlips.planId, row.id));
|
||||
return this.hydrateOne(row, destinations, invoices, packingSlips);
|
||||
const [plan] = await this.loadChildrenForRows(executor, [row]);
|
||||
if (!plan) {
|
||||
throw new NotFoundException('Plan not found');
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
private async loadChildrenForRows(
|
||||
executor: QueryExecutor,
|
||||
rows: PlanRow[],
|
||||
): Promise<Plan[]> {
|
||||
if (rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const ids = rows.map((row) => row.id);
|
||||
const [destinationRows, invoiceRows, packingSlipRows] = await Promise.all([
|
||||
executor
|
||||
.select()
|
||||
.from(planDestinations)
|
||||
.where(inArray(planDestinations.planId, ids))
|
||||
.orderBy(asc(planDestinations.sortOrder)),
|
||||
executor
|
||||
.select()
|
||||
.from(planInvoices)
|
||||
.where(inArray(planInvoices.planId, ids)),
|
||||
executor
|
||||
.select()
|
||||
.from(planPackingSlips)
|
||||
.where(inArray(planPackingSlips.planId, ids)),
|
||||
]);
|
||||
const destinationsByPlan = this.groupByPlanId(destinationRows);
|
||||
const invoicesByPlan = this.groupByPlanId(invoiceRows);
|
||||
const packingByPlan = this.groupByPlanId(packingSlipRows);
|
||||
return this.hydrate(
|
||||
rows.map((row) =>
|
||||
this.toDomain(
|
||||
row,
|
||||
destinationsByPlan.get(row.id) ?? [],
|
||||
invoicesByPlan.get(row.id) ?? [],
|
||||
packingByPlan.get(row.id) ?? [],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private groupByPlanId<T extends { planId: string }>(
|
||||
rows: T[],
|
||||
): Map<string, T[]> {
|
||||
const ids = [...new Set(rows.map((row) => row.planId))];
|
||||
return new Map(
|
||||
ids.map((planId) => [
|
||||
planId,
|
||||
rows.filter((row) => row.planId === planId),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
private async replaceChildren(
|
||||
|
||||
@@ -16,6 +16,37 @@ import type { Plan } from './plan';
|
||||
import { PlansRepository } from './plans.repository';
|
||||
import { PlansService } from './plans.service';
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
function todayYmd(): string {
|
||||
return DateTime.fromUnixMs(Math.trunc(Date.now()))
|
||||
.startOfDay()
|
||||
.format()
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
function nextWeekdayYmd(weekday: string): string {
|
||||
const start = DateTime.fromUnixMs(Math.trunc(Date.now())).startOfDay();
|
||||
for (let offset = 0; offset < 8; offset += 1) {
|
||||
const day = DateTime.fromUnixMs(
|
||||
start.value + offset * MS_PER_DAY,
|
||||
).startOfDay();
|
||||
if (day.weekdayName() === weekday) {
|
||||
return day.format().slice(0, 10);
|
||||
}
|
||||
}
|
||||
return todayYmd();
|
||||
}
|
||||
|
||||
function addDaysYmd(date: string, days: number): string {
|
||||
return DateTime.fromUnixMs(
|
||||
DateTime.create(date).startOfDay().value + days * MS_PER_DAY,
|
||||
)
|
||||
.startOfDay()
|
||||
.format()
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
describe('PlansService', () => {
|
||||
let service: PlansService;
|
||||
let plansRepository: jest.Mocked<
|
||||
@@ -41,7 +72,7 @@ describe('PlansService', () => {
|
||||
markDraftsProcessed: jest.fn(),
|
||||
};
|
||||
const packingSlipsService = { findById: jest.fn() };
|
||||
const privilegesService = { checkPermission: jest.fn() };
|
||||
const privilegesService = { checkPermission: jest.fn(), checkAnyPermission: jest.fn() };
|
||||
|
||||
const user: AuthUser = {
|
||||
id: 'user-1',
|
||||
@@ -170,7 +201,7 @@ describe('PlansService', () => {
|
||||
service.create({
|
||||
employeeId: 'emp-1',
|
||||
purpose: 'sales',
|
||||
date: '2026-01-05',
|
||||
date: todayYmd(),
|
||||
startBranchId: 'br-1',
|
||||
endBranchId: 'br-2',
|
||||
customerIds: ['cus-1'],
|
||||
@@ -180,15 +211,73 @@ describe('PlansService', () => {
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects a plan date in the past', async () => {
|
||||
await expect(
|
||||
service.create({
|
||||
employeeId: 'emp-1',
|
||||
purpose: 'sales',
|
||||
date: '2020-01-01',
|
||||
startBranchId: 'br-1',
|
||||
endBranchId: 'br-2',
|
||||
customerIds: ['cus-1'],
|
||||
userId: 'user-1',
|
||||
}),
|
||||
).rejects.toMatchObject({ message: 'Date cannot be in the past' });
|
||||
});
|
||||
|
||||
it('rejects invoices that do not belong to selected customers', async () => {
|
||||
salesInvoicesService.findById.mockResolvedValue({
|
||||
id: 'inv-1',
|
||||
customer: { id: 'cus-2', code: 'C2', name: 'Beta' },
|
||||
});
|
||||
await expect(
|
||||
service.create({
|
||||
employeeId: 'emp-1',
|
||||
purpose: 'sales',
|
||||
date: todayYmd(),
|
||||
startBranchId: 'br-1',
|
||||
endBranchId: 'br-2',
|
||||
customerIds: ['cus-1'],
|
||||
invoiceIds: ['inv-1'],
|
||||
userId: 'user-1',
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
message: 'Invoice does not belong to a selected customer',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps an existing past date when the date is unchanged', async () => {
|
||||
plansRepository.findById.mockResolvedValue(plan);
|
||||
plansRepository.update.mockResolvedValue(plan);
|
||||
await service.update(
|
||||
'pln-1',
|
||||
{ date: '2026-01-05', userId: 'user-1' },
|
||||
user,
|
||||
);
|
||||
expect(plansRepository.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects changing a plan date into the past', async () => {
|
||||
plansRepository.findById.mockResolvedValue({
|
||||
...plan,
|
||||
date: DateTime.create(todayYmd()),
|
||||
});
|
||||
await expect(
|
||||
service.update('pln-1', { date: '2020-01-01', userId: 'user-1' }, user),
|
||||
).rejects.toMatchObject({ message: 'Date cannot be in the past' });
|
||||
});
|
||||
|
||||
it('generate copies a weekday and skips an existing plan', async () => {
|
||||
const from = nextWeekdayYmd('monday');
|
||||
const to = addDaysYmd(from, 7);
|
||||
plansRepository.findLiveByKey
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(plan);
|
||||
const result = await service.generate({
|
||||
employeeId: 'emp-1',
|
||||
purpose: 'sales',
|
||||
from: '2026-01-05',
|
||||
to: '2026-01-12',
|
||||
from,
|
||||
to,
|
||||
userId: 'user-1',
|
||||
});
|
||||
expect(result.created).toBe(1);
|
||||
@@ -205,8 +294,8 @@ describe('PlansService', () => {
|
||||
service.generate({
|
||||
employeeId: 'emp-1',
|
||||
purpose: 'sales',
|
||||
from: '2026-01-05',
|
||||
to: '2026-01-05',
|
||||
from: todayYmd(),
|
||||
to: todayYmd(),
|
||||
userId: 'user-1',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
@@ -27,7 +27,7 @@ import { CyclesRepository } from '../cycles/cycles.repository';
|
||||
import { CompanySettingsService } from '../settings/company-settings.service';
|
||||
import {
|
||||
cycleNumberForDate,
|
||||
fieldPrivilegeKey,
|
||||
fieldPrivilegeKeys,
|
||||
isFieldPurpose,
|
||||
noCycleMessage,
|
||||
type FieldPurpose,
|
||||
@@ -173,6 +173,9 @@ export class PlansService {
|
||||
const date = input.date
|
||||
? this.assertDate(input.date).startOfDay()
|
||||
: existing.date;
|
||||
if (input.date && date.value !== existing.date.value) {
|
||||
this.assertNotInThePast(date);
|
||||
}
|
||||
await this.employeesService.findById(employeeId);
|
||||
await this.assertUnique(employeeId, purpose, date.value, id);
|
||||
const startBranchId = input.startBranchId ?? existing.startBranchId;
|
||||
@@ -189,12 +192,8 @@ export class PlansService {
|
||||
input.invoiceIds ?? [...existing.invoiceIds],
|
||||
input.packingSlipIds ?? [...existing.packingSlipIds],
|
||||
);
|
||||
if (input.invoiceIds) {
|
||||
await this.assertInvoices(input.invoiceIds);
|
||||
}
|
||||
if (input.packingSlipIds) {
|
||||
await this.assertPackingSlips(input.packingSlipIds);
|
||||
}
|
||||
await this.assertInvoices(attachments.invoiceIds, customerIds);
|
||||
await this.assertPackingSlips(attachments.packingSlipIds, customerIds);
|
||||
const updated = await this.plansRepository.update(id, {
|
||||
employeeId,
|
||||
purpose,
|
||||
@@ -278,6 +277,8 @@ export class PlansService {
|
||||
if (to.value < from.value) {
|
||||
throw new BadRequestException('Invalid date range');
|
||||
}
|
||||
this.assertNotInThePast(from);
|
||||
this.assertNotInThePast(to);
|
||||
if (from.value < epoch.startOfDay().value) {
|
||||
throw new BadRequestException('Date is before the cycle start date');
|
||||
}
|
||||
@@ -446,6 +447,7 @@ export class PlansService {
|
||||
}) {
|
||||
const purpose = this.assertPurpose(input.purpose);
|
||||
const date = this.assertDate(input.date).startOfDay();
|
||||
this.assertNotInThePast(date);
|
||||
await this.employeesService.findById(input.employeeId);
|
||||
await this.assertUnique(input.employeeId, purpose, date.value);
|
||||
const geometry = await this.buildGeometry(
|
||||
@@ -458,8 +460,11 @@ export class PlansService {
|
||||
input.invoiceIds ?? [],
|
||||
input.packingSlipIds ?? [],
|
||||
);
|
||||
await this.assertInvoices(attachments.invoiceIds);
|
||||
await this.assertPackingSlips(attachments.packingSlipIds);
|
||||
await this.assertInvoices(attachments.invoiceIds, input.customerIds);
|
||||
await this.assertPackingSlips(
|
||||
attachments.packingSlipIds,
|
||||
input.customerIds,
|
||||
);
|
||||
return {
|
||||
employeeId: input.employeeId,
|
||||
purpose,
|
||||
@@ -544,15 +549,35 @@ export class PlansService {
|
||||
await this.salesInvoicesService.markDraftsProcessed(newlyAttached, userId);
|
||||
}
|
||||
|
||||
private async assertInvoices(ids: readonly string[]): Promise<void> {
|
||||
private async assertInvoices(
|
||||
ids: readonly string[],
|
||||
customerIds: readonly string[],
|
||||
): Promise<void> {
|
||||
const allowed = new Set(customerIds);
|
||||
for (const id of ids) {
|
||||
await this.salesInvoicesService.findById(id);
|
||||
const invoice = await this.salesInvoicesService.findById(id);
|
||||
const customerId = invoice.customer?.id;
|
||||
if (!customerId || !allowed.has(customerId)) {
|
||||
throw new BadRequestException(
|
||||
'Invoice does not belong to a selected customer',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async assertPackingSlips(ids: readonly string[]): Promise<void> {
|
||||
private async assertPackingSlips(
|
||||
ids: readonly string[],
|
||||
customerIds: readonly string[],
|
||||
): Promise<void> {
|
||||
const allowed = new Set(customerIds);
|
||||
for (const id of ids) {
|
||||
await this.packingSlipsService.findById(id);
|
||||
const packingSlip = await this.packingSlipsService.findById(id);
|
||||
const customerId = packingSlip.customer?.id;
|
||||
if (!customerId || !allowed.has(customerId)) {
|
||||
throw new BadRequestException(
|
||||
'Packing slip does not belong to a selected customer',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -590,9 +615,9 @@ export class PlansService {
|
||||
}
|
||||
const allowed: FieldPurpose[] = [];
|
||||
for (const purpose of ['sales', 'logistics'] as const) {
|
||||
const ok = await this.privilegesService.checkPermission(
|
||||
const ok = await this.privilegesService.checkAnyPermission(
|
||||
user.id,
|
||||
fieldPrivilegeKey('plan', purpose),
|
||||
fieldPrivilegeKeys('plan', purpose),
|
||||
action,
|
||||
);
|
||||
if (ok) {
|
||||
@@ -610,9 +635,9 @@ export class PlansService {
|
||||
if (user.isSuperadmin) {
|
||||
return;
|
||||
}
|
||||
const ok = await this.privilegesService.checkPermission(
|
||||
const ok = await this.privilegesService.checkAnyPermission(
|
||||
user.id,
|
||||
fieldPrivilegeKey('plan', purpose),
|
||||
fieldPrivilegeKeys('plan', purpose),
|
||||
action,
|
||||
);
|
||||
if (!ok) {
|
||||
@@ -649,6 +674,13 @@ export class PlansService {
|
||||
}
|
||||
}
|
||||
|
||||
private assertNotInThePast(date: DateTime): void {
|
||||
const today = DateTime.fromUnixMs(Math.trunc(Date.now())).startOfDay();
|
||||
if (date.startOfDay().value < today.value) {
|
||||
throw new BadRequestException('Date cannot be in the past');
|
||||
}
|
||||
}
|
||||
|
||||
private assertStatus(raw: string): Status {
|
||||
try {
|
||||
return Status.create(raw);
|
||||
|
||||
@@ -4,6 +4,9 @@ import { Status } from '../../../common/value-objects/status/status';
|
||||
export type CompanySetting = {
|
||||
readonly id: string;
|
||||
readonly cycleStartDate: DateTime;
|
||||
readonly checkInRadiusMeters: number;
|
||||
readonly gpsIntervalSeconds: number;
|
||||
readonly checkoutWarningRadiusMeters: number;
|
||||
readonly status: Status;
|
||||
readonly createdAt: DateTime;
|
||||
readonly updatedAt: DateTime;
|
||||
@@ -13,5 +16,8 @@ export type CompanySetting = {
|
||||
|
||||
export type UpsertCompanySettingInput = {
|
||||
readonly cycleStartDate: DateTime;
|
||||
readonly checkInRadiusMeters?: number;
|
||||
readonly gpsIntervalSeconds?: number;
|
||||
readonly checkoutWarningRadiusMeters?: number;
|
||||
readonly userId: string;
|
||||
};
|
||||
|
||||
@@ -47,6 +47,6 @@ export class CompanySettingsController {
|
||||
@Body() dto: UpdateCompanySettingDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<CompanySettingDto> {
|
||||
return this.companySettingsService.update(dto.cycleStartDate, userId);
|
||||
return this.companySettingsService.update(dto, userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ export class CompanySettingsRepository {
|
||||
.insert(companySettings)
|
||||
.values({
|
||||
cycleStartDate: input.cycleStartDate.value,
|
||||
checkInRadiusMeters: input.checkInRadiusMeters ?? 100,
|
||||
gpsIntervalSeconds: input.gpsIntervalSeconds ?? 5,
|
||||
checkoutWarningRadiusMeters: input.checkoutWarningRadiusMeters ?? 200,
|
||||
status: Status.create(Status.DEFAULT).value,
|
||||
createdAt: now.value,
|
||||
updatedAt: now.value,
|
||||
@@ -46,6 +49,15 @@ export class CompanySettingsRepository {
|
||||
.update(companySettings)
|
||||
.set({
|
||||
cycleStartDate: input.cycleStartDate.value,
|
||||
...(input.checkInRadiusMeters !== undefined
|
||||
? { checkInRadiusMeters: input.checkInRadiusMeters }
|
||||
: {}),
|
||||
...(input.gpsIntervalSeconds !== undefined
|
||||
? { gpsIntervalSeconds: input.gpsIntervalSeconds }
|
||||
: {}),
|
||||
...(input.checkoutWarningRadiusMeters !== undefined
|
||||
? { checkoutWarningRadiusMeters: input.checkoutWarningRadiusMeters }
|
||||
: {}),
|
||||
updatedAt: now.value,
|
||||
updatedBy: input.userId,
|
||||
})
|
||||
@@ -58,6 +70,9 @@ export class CompanySettingsRepository {
|
||||
return {
|
||||
id: row.id,
|
||||
cycleStartDate: DateTime.fromUnixMs(row.cycleStartDate),
|
||||
checkInRadiusMeters: row.checkInRadiusMeters,
|
||||
gpsIntervalSeconds: row.gpsIntervalSeconds,
|
||||
checkoutWarningRadiusMeters: row.checkoutWarningRadiusMeters,
|
||||
status: Status.create(row.status),
|
||||
createdAt: DateTime.fromUnixMs(row.createdAt),
|
||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||
|
||||
@@ -16,6 +16,9 @@ describe('CompanySettingsService', () => {
|
||||
const sample: CompanySetting = {
|
||||
id: 'set-1',
|
||||
cycleStartDate: DateTime.create('2026-01-05'),
|
||||
checkInRadiusMeters: 100,
|
||||
gpsIntervalSeconds: 5,
|
||||
checkoutWarningRadiusMeters: 200,
|
||||
status: Status.create('draft'),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -46,19 +49,46 @@ describe('CompanySettingsService', () => {
|
||||
repository.find.mockResolvedValue(sample);
|
||||
const result = await service.get();
|
||||
expect(result.cycleStartDate).toBe(sample.cycleStartDate.value);
|
||||
expect(result.checkInRadiusMeters).toBe(100);
|
||||
expect(result.gpsIntervalSeconds).toBe(5);
|
||||
expect(result.checkoutWarningRadiusMeters).toBe(200);
|
||||
});
|
||||
|
||||
it('update persists start of day', async () => {
|
||||
repository.find.mockResolvedValue(sample);
|
||||
repository.upsert.mockResolvedValue(sample);
|
||||
await service.update('2026-01-05', 'user-1');
|
||||
await service.update({ cycleStartDate: '2026-01-05' }, 'user-1');
|
||||
const arg = repository.upsert.mock.calls[0][0];
|
||||
expect(arg.cycleStartDate.equals(DateTime.create('2026-01-05'))).toBe(true);
|
||||
expect(arg.userId).toBe('user-1');
|
||||
});
|
||||
|
||||
it('update rejects invalid dates', async () => {
|
||||
await expect(service.update('not-a-date', 'user-1')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
it('update rejects invalid check-in radius', async () => {
|
||||
repository.find.mockResolvedValue(sample);
|
||||
await expect(
|
||||
service.update({ checkInRadiusMeters: 0 }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('update rejects invalid GPS interval', async () => {
|
||||
repository.find.mockResolvedValue(sample);
|
||||
await expect(
|
||||
service.update({ gpsIntervalSeconds: 2 }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('requireTimelineConfig returns tracking settings', async () => {
|
||||
repository.find.mockResolvedValue(sample);
|
||||
await expect(service.requireTimelineConfig()).resolves.toEqual({
|
||||
gpsIntervalSeconds: 5,
|
||||
checkoutWarningRadiusMeters: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it('update rejects when settings are missing and no cycle date provided', async () => {
|
||||
repository.find.mockResolvedValue(null);
|
||||
await expect(service.update({}, 'user-1')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,17 +23,58 @@ export class CompanySettingsService {
|
||||
}
|
||||
|
||||
async update(
|
||||
cycleStartDateRaw: string,
|
||||
input: {
|
||||
cycleStartDate?: string;
|
||||
checkInRadiusMeters?: number;
|
||||
gpsIntervalSeconds?: number;
|
||||
checkoutWarningRadiusMeters?: number;
|
||||
},
|
||||
userId: string,
|
||||
): Promise<ReturnType<CompanySettingsService['toItem']>> {
|
||||
const cycleStartDate = this.assertDate(cycleStartDateRaw).startOfDay();
|
||||
const existing = await this.companySettingsRepository.find();
|
||||
const cycleStartDate = input.cycleStartDate
|
||||
? this.assertDate(input.cycleStartDate).startOfDay()
|
||||
: existing?.cycleStartDate;
|
||||
if (!cycleStartDate) {
|
||||
throw new NotFoundException('Settings not configured');
|
||||
}
|
||||
if (
|
||||
input.checkInRadiusMeters !== undefined &&
|
||||
(input.checkInRadiusMeters < 1 || input.checkInRadiusMeters > 10_000)
|
||||
) {
|
||||
throw new BadRequestException('Invalid check-in radius');
|
||||
}
|
||||
if (
|
||||
input.gpsIntervalSeconds !== undefined &&
|
||||
(input.gpsIntervalSeconds < 5 || input.gpsIntervalSeconds > 300)
|
||||
) {
|
||||
throw new BadRequestException('Invalid GPS interval');
|
||||
}
|
||||
if (
|
||||
input.checkoutWarningRadiusMeters !== undefined &&
|
||||
(input.checkoutWarningRadiusMeters < 1 ||
|
||||
input.checkoutWarningRadiusMeters > 10_000)
|
||||
) {
|
||||
throw new BadRequestException('Invalid checkout warning radius');
|
||||
}
|
||||
const saved = await this.companySettingsRepository.upsert({
|
||||
cycleStartDate,
|
||||
checkInRadiusMeters: input.checkInRadiusMeters,
|
||||
gpsIntervalSeconds: input.gpsIntervalSeconds,
|
||||
checkoutWarningRadiusMeters: input.checkoutWarningRadiusMeters,
|
||||
userId,
|
||||
});
|
||||
return this.toItem(saved);
|
||||
}
|
||||
|
||||
async requireCheckInRadiusMeters(): Promise<number> {
|
||||
const setting = await this.companySettingsRepository.find();
|
||||
if (!setting) {
|
||||
throw new NotFoundException('Settings not configured');
|
||||
}
|
||||
return setting.checkInRadiusMeters;
|
||||
}
|
||||
|
||||
async requireCycleStartDate(): Promise<DateTime> {
|
||||
const setting = await this.companySettingsRepository.find();
|
||||
if (!setting) {
|
||||
@@ -42,10 +83,27 @@ export class CompanySettingsService {
|
||||
return setting.cycleStartDate;
|
||||
}
|
||||
|
||||
async requireTimelineConfig(): Promise<{
|
||||
gpsIntervalSeconds: number;
|
||||
checkoutWarningRadiusMeters: number;
|
||||
}> {
|
||||
const setting = await this.companySettingsRepository.find();
|
||||
if (!setting) {
|
||||
throw new NotFoundException('Settings not configured');
|
||||
}
|
||||
return {
|
||||
gpsIntervalSeconds: setting.gpsIntervalSeconds,
|
||||
checkoutWarningRadiusMeters: setting.checkoutWarningRadiusMeters,
|
||||
};
|
||||
}
|
||||
|
||||
toItem(setting: CompanySetting) {
|
||||
return {
|
||||
id: setting.id,
|
||||
cycleStartDate: setting.cycleStartDate.value,
|
||||
checkInRadiusMeters: setting.checkInRadiusMeters,
|
||||
gpsIntervalSeconds: setting.gpsIntervalSeconds,
|
||||
checkoutWarningRadiusMeters: setting.checkoutWarningRadiusMeters,
|
||||
status: setting.status.value,
|
||||
createdAt: setting.createdAt.value,
|
||||
updatedAt: setting.updatedAt.value,
|
||||
|
||||
@@ -1,14 +1,44 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, Matches } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateCompanySettingDto {
|
||||
@ApiProperty({ example: '2026-01-05', description: 'YYYY-MM-DD' })
|
||||
@ApiPropertyOptional({ example: '2026-01-05', description: 'YYYY-MM-DD' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/, {
|
||||
message: 'cycleStartDate must be a calendar date',
|
||||
})
|
||||
cycleStartDate!: string;
|
||||
cycleStartDate?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 100, minimum: 1, maximum: 10000 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(10_000)
|
||||
checkInRadiusMeters?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 5, minimum: 5, maximum: 300 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(5)
|
||||
@Max(300)
|
||||
gpsIntervalSeconds?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 200, minimum: 1, maximum: 10000 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(10_000)
|
||||
checkoutWarningRadiusMeters?: number;
|
||||
}
|
||||
|
||||
export class CompanySettingDto {
|
||||
@@ -18,6 +48,15 @@ export class CompanySettingDto {
|
||||
@ApiProperty({ description: 'Unix ms start of the cycle-start calendar day' })
|
||||
cycleStartDate!: number;
|
||||
|
||||
@ApiProperty({ example: 100 })
|
||||
checkInRadiusMeters!: number;
|
||||
|
||||
@ApiProperty({ example: 5 })
|
||||
gpsIntervalSeconds!: number;
|
||||
|
||||
@ApiProperty({ example: 200 })
|
||||
checkoutWarningRadiusMeters!: number;
|
||||
|
||||
@ApiProperty()
|
||||
status!: string;
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
verifyBranchCheckIn,
|
||||
verifyCustomerCheckIn,
|
||||
} from './check-in-verification';
|
||||
import { haversineDistanceMeters } from './geo-distance';
|
||||
|
||||
describe('geo-distance', () => {
|
||||
it('returns zero for identical coordinates', () => {
|
||||
expect(haversineDistanceMeters(-6.2, 106.8, -6.2, 106.8)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('check-in-verification', () => {
|
||||
const target = {
|
||||
code: 'BR001',
|
||||
nfcId: 'nfc-123',
|
||||
latitude: -6.2,
|
||||
longitude: 106.8,
|
||||
};
|
||||
|
||||
it('accepts matching NFC tag', () => {
|
||||
const result = verifyBranchCheckIn(
|
||||
target,
|
||||
{
|
||||
method: 'nfc',
|
||||
nfcId: 'nfc-123',
|
||||
latitude: -6.2,
|
||||
longitude: 106.8,
|
||||
},
|
||||
100,
|
||||
);
|
||||
expect(result.method).toBe('nfc');
|
||||
});
|
||||
|
||||
it('accepts matching QR code', () => {
|
||||
const result = verifyBranchCheckIn(
|
||||
target,
|
||||
{
|
||||
method: 'qr',
|
||||
qrCode: 'BR001',
|
||||
latitude: -6.2,
|
||||
longitude: 106.8,
|
||||
},
|
||||
100,
|
||||
);
|
||||
expect(result.method).toBe('qr');
|
||||
});
|
||||
|
||||
it('rejects GPS when too far', () => {
|
||||
expect(() =>
|
||||
verifyCustomerCheckIn(
|
||||
target,
|
||||
{
|
||||
method: 'gps',
|
||||
latitude: -7,
|
||||
longitude: 107.5,
|
||||
},
|
||||
100,
|
||||
),
|
||||
).toThrow('Too far from the customer location');
|
||||
});
|
||||
|
||||
it('accepts GPS when too far if skipGpsValidation is set', () => {
|
||||
const result = verifyBranchCheckIn(
|
||||
target,
|
||||
{
|
||||
method: 'gps',
|
||||
latitude: -7,
|
||||
longitude: 107.5,
|
||||
},
|
||||
100,
|
||||
{ skipGpsValidation: true },
|
||||
);
|
||||
|
||||
expect(result.method).toBe('gps');
|
||||
expect(result.distanceMeters).toBeGreaterThan(100);
|
||||
expect(Number.isInteger(result.distanceMeters)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns whole meters for NFC distance so it matches the integer column', () => {
|
||||
const result = verifyBranchCheckIn(
|
||||
target,
|
||||
{
|
||||
method: 'nfc',
|
||||
nfcId: 'nfc-123',
|
||||
latitude: -6.2005,
|
||||
longitude: 106.8005,
|
||||
},
|
||||
100,
|
||||
);
|
||||
|
||||
expect(result.distanceMeters).not.toBeNull();
|
||||
expect(Number.isInteger(result.distanceMeters)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts GPS when the target has no location if skipGpsValidation is set', () => {
|
||||
const result = verifyCustomerCheckIn(
|
||||
{ ...target, latitude: null, longitude: null },
|
||||
{
|
||||
method: 'gps',
|
||||
latitude: -7,
|
||||
longitude: 107.5,
|
||||
},
|
||||
100,
|
||||
{ skipGpsValidation: true },
|
||||
);
|
||||
|
||||
expect(result.method).toBe('gps');
|
||||
expect(result.distanceMeters).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { haversineDistanceMeters } from './geo-distance';
|
||||
|
||||
function roundedDistanceMeters(
|
||||
lat1: number,
|
||||
lon1: number,
|
||||
lat2: number,
|
||||
lon2: number,
|
||||
): number {
|
||||
return Math.round(haversineDistanceMeters(lat1, lon1, lat2, lon2));
|
||||
}
|
||||
|
||||
export const CHECK_IN_METHODS = ['nfc', 'qr', 'gps'] as const;
|
||||
export type CheckInMethod = (typeof CHECK_IN_METHODS)[number];
|
||||
|
||||
export function isCheckInMethod(raw: string): raw is CheckInMethod {
|
||||
return (CHECK_IN_METHODS as readonly string[]).includes(raw);
|
||||
}
|
||||
|
||||
export type CheckInPayload = {
|
||||
readonly method: CheckInMethod;
|
||||
readonly nfcId?: string;
|
||||
readonly qrCode?: string;
|
||||
readonly latitude: number;
|
||||
readonly longitude: number;
|
||||
readonly photoUrl?: string;
|
||||
};
|
||||
|
||||
export type CheckInVerificationResult = {
|
||||
readonly latitude: number;
|
||||
readonly longitude: number;
|
||||
readonly distanceMeters: number | null;
|
||||
readonly method: CheckInMethod;
|
||||
readonly photoUrl: string | null;
|
||||
};
|
||||
|
||||
export type CheckInVerificationOptions = {
|
||||
readonly skipGpsValidation?: boolean;
|
||||
};
|
||||
|
||||
export type BranchCheckInTarget = {
|
||||
readonly code: string;
|
||||
readonly nfcId: string | null;
|
||||
readonly latitude: number | null;
|
||||
readonly longitude: number | null;
|
||||
};
|
||||
|
||||
export type CustomerCheckInTarget = {
|
||||
readonly code: string;
|
||||
readonly nfcId: string | null;
|
||||
readonly latitude: number | null;
|
||||
readonly longitude: number | null;
|
||||
};
|
||||
|
||||
export function verifyBranchCheckIn(
|
||||
target: BranchCheckInTarget,
|
||||
payload: CheckInPayload,
|
||||
radiusMeters: number,
|
||||
options?: CheckInVerificationOptions,
|
||||
): CheckInVerificationResult {
|
||||
return verifyTargetCheckIn(
|
||||
target,
|
||||
payload,
|
||||
radiusMeters,
|
||||
'NFC tag does not match this branch',
|
||||
'QR code does not match this branch',
|
||||
'Branch location is not configured',
|
||||
'Too far from the branch location',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function verifyCustomerCheckIn(
|
||||
target: CustomerCheckInTarget,
|
||||
payload: CheckInPayload,
|
||||
radiusMeters: number,
|
||||
options?: CheckInVerificationOptions,
|
||||
): CheckInVerificationResult {
|
||||
return verifyTargetCheckIn(
|
||||
target,
|
||||
payload,
|
||||
radiusMeters,
|
||||
'NFC tag does not match this customer',
|
||||
'QR code does not match this customer',
|
||||
'Customer location is not configured',
|
||||
'Too far from the customer location',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
function verifyTargetCheckIn(
|
||||
target: BranchCheckInTarget | CustomerCheckInTarget,
|
||||
payload: CheckInPayload,
|
||||
radiusMeters: number,
|
||||
nfcMismatchMessage: string,
|
||||
qrMismatchMessage: string,
|
||||
locationMissingMessage: string,
|
||||
tooFarMessage: string,
|
||||
options?: CheckInVerificationOptions,
|
||||
): CheckInVerificationResult {
|
||||
if (!isCheckInMethod(payload.method)) {
|
||||
throw new BadRequestException('Invalid check-in method');
|
||||
}
|
||||
|
||||
let distanceMeters: number | null = null;
|
||||
|
||||
if (payload.method === 'nfc') {
|
||||
if (!payload.nfcId?.trim()) {
|
||||
throw new BadRequestException('NFC tag is required');
|
||||
}
|
||||
if (!target.nfcId || payload.nfcId.trim() !== target.nfcId) {
|
||||
throw new BadRequestException(nfcMismatchMessage);
|
||||
}
|
||||
} else if (payload.method === 'qr') {
|
||||
if (!payload.qrCode?.trim()) {
|
||||
throw new BadRequestException('QR code is required');
|
||||
}
|
||||
if (payload.qrCode.trim() !== target.code) {
|
||||
throw new BadRequestException(qrMismatchMessage);
|
||||
}
|
||||
} else {
|
||||
const targetLatitude = target.latitude;
|
||||
const targetLongitude = target.longitude;
|
||||
const canMeasure = targetLatitude != null && targetLongitude != null;
|
||||
if (!canMeasure && !options?.skipGpsValidation) {
|
||||
throw new BadRequestException(locationMissingMessage);
|
||||
}
|
||||
if (canMeasure) {
|
||||
distanceMeters = roundedDistanceMeters(
|
||||
payload.latitude,
|
||||
payload.longitude,
|
||||
targetLatitude,
|
||||
targetLongitude,
|
||||
);
|
||||
if (!options?.skipGpsValidation && distanceMeters > radiusMeters) {
|
||||
throw new BadRequestException(tooFarMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
latitude: payload.latitude,
|
||||
longitude: payload.longitude,
|
||||
distanceMeters:
|
||||
distanceMeters == null
|
||||
? target.latitude != null && target.longitude != null
|
||||
? roundedDistanceMeters(
|
||||
payload.latitude,
|
||||
payload.longitude,
|
||||
target.latitude,
|
||||
target.longitude,
|
||||
)
|
||||
: null
|
||||
: distanceMeters,
|
||||
method: payload.method,
|
||||
photoUrl: payload.photoUrl?.trim() ? payload.photoUrl.trim() : null,
|
||||
};
|
||||
}
|
||||
@@ -10,14 +10,14 @@ import { REQUIRE_FIELD_PRIVILEGE_KEY } from './field-privilege.decorator';
|
||||
import { FieldPrivilegeGuard } from './field-privilege.guard';
|
||||
|
||||
describe('FieldPrivilegeGuard', () => {
|
||||
const checkPermission = jest.fn();
|
||||
const checkAnyPermission = jest.fn();
|
||||
const getAllAndOverride = jest.fn();
|
||||
const reflector = {
|
||||
getAllAndOverride,
|
||||
} as unknown as Reflector;
|
||||
|
||||
const guard = new FieldPrivilegeGuard(reflector, {
|
||||
checkPermission,
|
||||
checkAnyPermission,
|
||||
} as never);
|
||||
|
||||
const user: AuthUser = {
|
||||
@@ -48,7 +48,7 @@ describe('FieldPrivilegeGuard', () => {
|
||||
it('allows when no field privilege metadata', async () => {
|
||||
getAllAndOverride.mockReturnValue(undefined);
|
||||
await expect(guard.canActivate(createContext(user))).resolves.toBe(true);
|
||||
expect(checkPermission).not.toHaveBeenCalled();
|
||||
expect(checkAnyPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows sales purpose when sales key is granted', async () => {
|
||||
@@ -57,8 +57,9 @@ describe('FieldPrivilegeGuard', () => {
|
||||
action: 'create',
|
||||
};
|
||||
getAllAndOverride.mockReturnValue(meta);
|
||||
checkPermission.mockImplementation((_id: string, key: string) =>
|
||||
Promise.resolve(key === 'SALES.CYCLE'),
|
||||
checkAnyPermission.mockImplementation(
|
||||
(_id: string, keys: readonly string[]) =>
|
||||
Promise.resolve(keys.includes('ADMIN.SALES.DATA.CYCLE')),
|
||||
);
|
||||
|
||||
await expect(
|
||||
@@ -72,8 +73,9 @@ describe('FieldPrivilegeGuard', () => {
|
||||
action: 'update',
|
||||
};
|
||||
getAllAndOverride.mockReturnValue(meta);
|
||||
checkPermission.mockImplementation((_id: string, key: string) =>
|
||||
Promise.resolve(key === 'SALES.PLAN'),
|
||||
checkAnyPermission.mockImplementation(
|
||||
(_id: string, keys: readonly string[]) =>
|
||||
Promise.resolve(keys.includes('MOBILE.SALES.PLAN')),
|
||||
);
|
||||
|
||||
await expect(
|
||||
@@ -87,8 +89,9 @@ describe('FieldPrivilegeGuard', () => {
|
||||
action: 'view',
|
||||
};
|
||||
getAllAndOverride.mockReturnValue(meta);
|
||||
checkPermission.mockImplementation((_id: string, key: string) =>
|
||||
Promise.resolve(key === 'LOGISTICS.CYCLE'),
|
||||
checkAnyPermission.mockImplementation(
|
||||
(_id: string, keys: readonly string[]) =>
|
||||
Promise.resolve(keys.includes('ADMIN.LOGISTICS.DATA.CYCLE')),
|
||||
);
|
||||
|
||||
await expect(guard.canActivate(createContext(user, {}, {}))).resolves.toBe(
|
||||
@@ -106,7 +109,7 @@ describe('FieldPrivilegeGuard', () => {
|
||||
await expect(
|
||||
guard.canActivate(createContext({ ...user, isSuperadmin: true })),
|
||||
).resolves.toBe(true);
|
||||
expect(checkPermission).not.toHaveBeenCalled();
|
||||
expect(checkAnyPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('unauthorized when metadata present but no user', async () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { AuthUser } from '../../../common/auth/auth-user';
|
||||
import type { PrivilegeAction } from '../../privileges/privilege-action';
|
||||
import { PrivilegesService } from '../../privileges/privileges.service';
|
||||
import {
|
||||
fieldPrivilegeKey,
|
||||
fieldPrivilegeKeys,
|
||||
isFieldPurpose,
|
||||
type FieldPurpose,
|
||||
type FieldResource,
|
||||
@@ -78,9 +78,9 @@ export class FieldPrivilegeGuard implements CanActivate {
|
||||
const purposes: FieldPurpose[] = ['sales', 'logistics'];
|
||||
const matches: FieldPurpose[] = [];
|
||||
for (const purpose of purposes) {
|
||||
const ok = await this.privilegesService.checkPermission(
|
||||
const ok = await this.privilegesService.checkAnyPermission(
|
||||
userId,
|
||||
fieldPrivilegeKey(resource, purpose),
|
||||
fieldPrivilegeKeys(resource, purpose),
|
||||
action,
|
||||
);
|
||||
if (ok) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
cycleNumberForDate,
|
||||
fieldPrivilegeKey,
|
||||
fieldPrivilegeKeys,
|
||||
isFieldPurpose,
|
||||
isWeekdayName,
|
||||
noCycleMessage,
|
||||
@@ -19,8 +19,13 @@ describe('field purpose helpers', () => {
|
||||
});
|
||||
|
||||
it('maps resource and purpose to privilege keys', () => {
|
||||
expect(fieldPrivilegeKey('cycle', 'sales')).toBe('SALES.CYCLE');
|
||||
expect(fieldPrivilegeKey('plan', 'logistics')).toBe('LOGISTICS.PLAN');
|
||||
expect(fieldPrivilegeKeys('cycle', 'sales')).toEqual([
|
||||
'ADMIN.SALES.DATA.CYCLE',
|
||||
]);
|
||||
expect(fieldPrivilegeKeys('plan', 'logistics')).toEqual([
|
||||
'ADMIN.LOGISTICS.ACTIVITIES.PLAN',
|
||||
'MOBILE.LOGISTICS.PLAN',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns purpose-specific missing-cycle messages', () => {
|
||||
|
||||
@@ -22,26 +22,34 @@ export function isWeekdayName(raw: string): raw is WeekdayName {
|
||||
return (WEEKDAY_NAMES as readonly string[]).includes(raw);
|
||||
}
|
||||
|
||||
export const SALES_CYCLE_PRIVILEGE_KEY = 'SALES.CYCLE';
|
||||
export const SALES_PLAN_PRIVILEGE_KEY = 'SALES.PLAN';
|
||||
export const LOGISTICS_CYCLE_PRIVILEGE_KEY = 'LOGISTICS.CYCLE';
|
||||
export const LOGISTICS_PLAN_PRIVILEGE_KEY = 'LOGISTICS.PLAN';
|
||||
export const SETTINGS_PRIVILEGE_KEY = 'CONFIGURATION.SETTING';
|
||||
export const ADMIN_SALES_CYCLE_PRIVILEGE_KEY = 'ADMIN.SALES.DATA.CYCLE';
|
||||
export const ADMIN_SALES_PLAN_PRIVILEGE_KEY = 'ADMIN.SALES.ACTIVITIES.PLAN';
|
||||
export const ADMIN_LOGISTICS_CYCLE_PRIVILEGE_KEY = 'ADMIN.LOGISTICS.DATA.CYCLE';
|
||||
export const ADMIN_LOGISTICS_PLAN_PRIVILEGE_KEY =
|
||||
'ADMIN.LOGISTICS.ACTIVITIES.PLAN';
|
||||
export const MOBILE_SALES_PLAN_PRIVILEGE_KEY = 'MOBILE.SALES.PLAN';
|
||||
export const MOBILE_LOGISTICS_PLAN_PRIVILEGE_KEY = 'MOBILE.LOGISTICS.PLAN';
|
||||
export const SETTINGS_PRIVILEGE_KEY = 'ADMIN.SETTINGS.DATA.SETTING';
|
||||
export const ADMIN_SALES_TIMELINE_PRIVILEGE_KEY =
|
||||
'ADMIN.SALES.ACTIVITIES.TIMELINE';
|
||||
export const MOBILE_SALES_TIMELINE_PRIVILEGE_KEY = 'MOBILE.SALES.TIMELINE';
|
||||
export const FIELD_ATTENDANCE_PRIVILEGE_KEY = 'MOBILE.SALES.PLAN.ATTENDANCE';
|
||||
export const FIELD_VISIT_PRIVILEGE_KEY = 'MOBILE.SALES.VISIT';
|
||||
|
||||
export type FieldResource = 'cycle' | 'plan';
|
||||
|
||||
export function fieldPrivilegeKey(
|
||||
export function fieldPrivilegeKeys(
|
||||
resource: FieldResource,
|
||||
purpose: FieldPurpose,
|
||||
): string {
|
||||
): readonly string[] {
|
||||
if (resource === 'cycle') {
|
||||
return purpose === 'sales'
|
||||
? SALES_CYCLE_PRIVILEGE_KEY
|
||||
: LOGISTICS_CYCLE_PRIVILEGE_KEY;
|
||||
? [ADMIN_SALES_CYCLE_PRIVILEGE_KEY]
|
||||
: [ADMIN_LOGISTICS_CYCLE_PRIVILEGE_KEY];
|
||||
}
|
||||
return purpose === 'sales'
|
||||
? SALES_PLAN_PRIVILEGE_KEY
|
||||
: LOGISTICS_PLAN_PRIVILEGE_KEY;
|
||||
? [ADMIN_SALES_PLAN_PRIVILEGE_KEY, MOBILE_SALES_PLAN_PRIVILEGE_KEY]
|
||||
: [ADMIN_LOGISTICS_PLAN_PRIVILEGE_KEY, MOBILE_LOGISTICS_PLAN_PRIVILEGE_KEY];
|
||||
}
|
||||
|
||||
export function noCycleMessage(purpose: FieldPurpose): string {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Haversine distance between two WGS-84 coordinates in meters.
|
||||
*/
|
||||
export function haversineDistanceMeters(
|
||||
lat1: number,
|
||||
lon1: number,
|
||||
lat2: number,
|
||||
lon2: number,
|
||||
): number {
|
||||
const earthRadiusMeters = 6_371_000;
|
||||
const toRadians = (degrees: number) => (degrees * Math.PI) / 180;
|
||||
const dLat = toRadians(lat2 - lat1);
|
||||
const dLon = toRadians(lon2 - lon1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRadians(lat1)) *
|
||||
Math.cos(toRadians(lat2)) *
|
||||
Math.sin(dLon / 2) ** 2;
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return earthRadiusMeters * c;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
Max,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
DefaultRelationDto,
|
||||
PaginationMetaDto,
|
||||
} from '../../../../common/http/response';
|
||||
import { TIMELINE_ACTIVITY_TYPES } from '../../../../database/timeline-activities-table';
|
||||
|
||||
export class TimelineConfigDto {
|
||||
@ApiProperty({ example: 5 })
|
||||
gpsIntervalSeconds!: number;
|
||||
|
||||
@ApiProperty({ example: 200 })
|
||||
checkoutWarningRadiusMeters!: number;
|
||||
}
|
||||
|
||||
export class TimelineFootprintPointDto {
|
||||
@ApiProperty({ example: -6.2 })
|
||||
@IsNumber()
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
latitude!: number;
|
||||
|
||||
@ApiProperty({ example: 106.8 })
|
||||
@IsNumber()
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
longitude!: number;
|
||||
|
||||
@ApiProperty({ description: 'Unix ms when the point was recorded' })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
recordedAt!: number;
|
||||
}
|
||||
|
||||
export class IngestTimelineFootprintsDto {
|
||||
@ApiProperty({ type: [TimelineFootprintPointDto] })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@ArrayMaxSize(100)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TimelineFootprintPointDto)
|
||||
points!: TimelineFootprintPointDto[];
|
||||
}
|
||||
|
||||
export class IngestTimelineFootprintsResultDto {
|
||||
@ApiProperty()
|
||||
inserted!: number;
|
||||
}
|
||||
|
||||
export class ListTimelineQueryDto {
|
||||
@ApiPropertyOptional({ example: '2026-09-01', description: 'YYYY-MM-DD' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'date must be YYYY-MM-DD' })
|
||||
date?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
employeeId?: string;
|
||||
}
|
||||
|
||||
export class TimelineFootprintDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ type: DefaultRelationDto })
|
||||
employee!: DefaultRelationDto;
|
||||
|
||||
@ApiProperty()
|
||||
latitude!: number;
|
||||
|
||||
@ApiProperty()
|
||||
longitude!: number;
|
||||
|
||||
@ApiProperty()
|
||||
recordedAt!: number;
|
||||
}
|
||||
|
||||
export class TimelineActivityDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ type: DefaultRelationDto })
|
||||
employee!: DefaultRelationDto;
|
||||
|
||||
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||
customer!: DefaultRelationDto | null;
|
||||
|
||||
@ApiProperty({ format: 'uuid', nullable: true })
|
||||
visitId!: string | null;
|
||||
|
||||
@ApiProperty({ enum: TIMELINE_ACTIVITY_TYPES })
|
||||
type!: string;
|
||||
|
||||
@ApiProperty()
|
||||
sourceType!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
sourceId!: string;
|
||||
|
||||
@ApiProperty()
|
||||
latitude!: number;
|
||||
|
||||
@ApiProperty()
|
||||
longitude!: number;
|
||||
|
||||
@ApiProperty()
|
||||
recordedAt!: number;
|
||||
}
|
||||
|
||||
export class TimelineDayDto {
|
||||
@ApiProperty()
|
||||
date!: string;
|
||||
|
||||
@ApiProperty({ type: [TimelineFootprintDto] })
|
||||
footprints!: TimelineFootprintDto[];
|
||||
|
||||
@ApiProperty({ type: [TimelineActivityDto] })
|
||||
activities!: TimelineActivityDto[];
|
||||
}
|
||||
|
||||
export class TimelineMeDto {
|
||||
@ApiProperty()
|
||||
date!: string;
|
||||
|
||||
@ApiProperty({ type: [TimelineActivityDto] })
|
||||
activities!: TimelineActivityDto[];
|
||||
}
|
||||
|
||||
void PaginationMetaDto;
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, eq, gte, isNull, lte, sql } from 'drizzle-orm';
|
||||
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||
import { customers } from '../../../database/customers-table';
|
||||
import {
|
||||
timelineActivities,
|
||||
type TimelineActivityRow,
|
||||
type TimelineActivityType,
|
||||
} from '../../../database/timeline-activities-table';
|
||||
import { visits } from '../../../database/visits-table';
|
||||
import { employees } from '../../../database/schema';
|
||||
import type {
|
||||
ListTimelineFilters,
|
||||
RecordTimelineActivityInput,
|
||||
TimelineActivity,
|
||||
} from './timeline.types';
|
||||
|
||||
@Injectable()
|
||||
export class TimelineActivitiesRepository {
|
||||
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||
|
||||
async insert(input: RecordTimelineActivityInput): Promise<TimelineActivity> {
|
||||
const recordedAt = input.recordedAt ?? Date.now();
|
||||
const inserted = await this.db
|
||||
.insert(timelineActivities)
|
||||
.values({
|
||||
employeeId: input.employeeId,
|
||||
customerId: input.customerId ?? null,
|
||||
visitId: input.visitId ?? null,
|
||||
type: input.type,
|
||||
sourceType: input.sourceType,
|
||||
sourceId: input.sourceId,
|
||||
latitude: input.latitude,
|
||||
longitude: input.longitude,
|
||||
recordedAt,
|
||||
})
|
||||
.returning();
|
||||
const row = inserted[0];
|
||||
return {
|
||||
id: row.id,
|
||||
employeeId: row.employeeId,
|
||||
customerId: row.customerId,
|
||||
visitId: row.visitId,
|
||||
type: row.type as TimelineActivityType,
|
||||
sourceType: row.sourceType,
|
||||
sourceId: row.sourceId,
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
recordedAt: row.recordedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async findOpenVisitForEmployee(
|
||||
employeeId: string,
|
||||
): Promise<{ visitId: string; customerId: string } | null> {
|
||||
const rows = await this.db
|
||||
.select({
|
||||
visitId: visits.id,
|
||||
customerId: visits.customerId,
|
||||
})
|
||||
.from(visits)
|
||||
.where(
|
||||
and(
|
||||
eq(visits.employeeId, employeeId),
|
||||
isNull(visits.checkOutAt),
|
||||
sql`${visits.status} <> 'archived'`,
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row ? { visitId: row.visitId, customerId: row.customerId } : null;
|
||||
}
|
||||
|
||||
async list(filters: ListTimelineFilters): Promise<TimelineActivity[]> {
|
||||
const conditions = [
|
||||
gte(timelineActivities.recordedAt, filters.dayStartMs),
|
||||
lte(timelineActivities.recordedAt, filters.dayEndMs),
|
||||
];
|
||||
if (filters.employeeId) {
|
||||
conditions.push(eq(timelineActivities.employeeId, filters.employeeId));
|
||||
}
|
||||
|
||||
const rows = await this.db
|
||||
.select({
|
||||
activity: timelineActivities,
|
||||
employee: employees,
|
||||
customer: customers,
|
||||
})
|
||||
.from(timelineActivities)
|
||||
.innerJoin(employees, eq(timelineActivities.employeeId, employees.id))
|
||||
.leftJoin(customers, eq(timelineActivities.customerId, customers.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(timelineActivities.recordedAt);
|
||||
|
||||
return rows.map((row) =>
|
||||
this.toDomain(row.activity, row.employee, row.customer),
|
||||
);
|
||||
}
|
||||
|
||||
private toDomain(
|
||||
row: TimelineActivityRow,
|
||||
employee?: typeof employees.$inferSelect,
|
||||
customer?: typeof customers.$inferSelect | null,
|
||||
): TimelineActivity {
|
||||
return {
|
||||
id: row.id,
|
||||
employeeId: row.employeeId,
|
||||
customerId: row.customerId,
|
||||
visitId: row.visitId,
|
||||
type: row.type as TimelineActivityType,
|
||||
sourceType: row.sourceType,
|
||||
sourceId: row.sourceId,
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
recordedAt: row.recordedAt,
|
||||
...(employee
|
||||
? {
|
||||
employee: {
|
||||
id: employee.id,
|
||||
code: employee.code,
|
||||
name: employee.name,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
customer: customer
|
||||
? { id: customer.id, code: customer.code, name: customer.name }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { TimelineActivityType } from '../../../database/timeline-activities-table';
|
||||
import { TimelineActivitiesRepository } from './timeline-activities.repository';
|
||||
import type { RecordTimelineActivityInput } from './timeline.types';
|
||||
|
||||
@Injectable()
|
||||
export class TimelineActivitiesService {
|
||||
constructor(
|
||||
private readonly timelineActivitiesRepository: TimelineActivitiesRepository,
|
||||
) {}
|
||||
|
||||
async record(input: RecordTimelineActivityInput): Promise<void> {
|
||||
const openVisit =
|
||||
input.visitId === undefined && input.customerId === undefined
|
||||
? await this.timelineActivitiesRepository.findOpenVisitForEmployee(
|
||||
input.employeeId,
|
||||
)
|
||||
: null;
|
||||
|
||||
await this.timelineActivitiesRepository.insert({
|
||||
...input,
|
||||
visitId: input.visitId ?? openVisit?.visitId ?? null,
|
||||
customerId:
|
||||
input.customerId ??
|
||||
openVisit?.customerId ??
|
||||
(input.type === 'customer_created' ? input.sourceId : null),
|
||||
});
|
||||
}
|
||||
|
||||
async recordIfLocated(input: {
|
||||
readonly employeeId: string;
|
||||
readonly type: TimelineActivityType;
|
||||
readonly sourceType: string;
|
||||
readonly sourceId: string;
|
||||
readonly latitude?: number | null;
|
||||
readonly longitude?: number | null;
|
||||
readonly recordedAt?: number;
|
||||
readonly customerId?: string | null;
|
||||
readonly visitId?: string | null;
|
||||
}): Promise<void> {
|
||||
if (
|
||||
input.latitude === undefined ||
|
||||
input.latitude === null ||
|
||||
input.longitude === undefined ||
|
||||
input.longitude === null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.record({
|
||||
employeeId: input.employeeId,
|
||||
type: input.type,
|
||||
sourceType: input.sourceType,
|
||||
sourceId: input.sourceId,
|
||||
latitude: input.latitude,
|
||||
longitude: input.longitude,
|
||||
recordedAt: input.recordedAt,
|
||||
customerId: input.customerId,
|
||||
visitId: input.visitId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, eq, gte, lte } from 'drizzle-orm';
|
||||
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||
import {
|
||||
timelineFootprints,
|
||||
type TimelineFootprintRow,
|
||||
} from '../../../database/timeline-footprints-table';
|
||||
import { employees } from '../../../database/schema';
|
||||
import type {
|
||||
IngestFootprintPoint,
|
||||
ListTimelineFilters,
|
||||
TimelineFootprint,
|
||||
} from './timeline.types';
|
||||
|
||||
@Injectable()
|
||||
export class TimelineFootprintsRepository {
|
||||
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||
|
||||
async insertMany(
|
||||
employeeId: string,
|
||||
points: readonly IngestFootprintPoint[],
|
||||
): Promise<number> {
|
||||
if (points.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const rows = points.map((point) => ({
|
||||
employeeId,
|
||||
latitude: point.latitude,
|
||||
longitude: point.longitude,
|
||||
recordedAt: point.recordedAt,
|
||||
}));
|
||||
const inserted = await this.db
|
||||
.insert(timelineFootprints)
|
||||
.values(rows)
|
||||
.returning({ id: timelineFootprints.id });
|
||||
return inserted.length;
|
||||
}
|
||||
|
||||
async list(filters: ListTimelineFilters): Promise<TimelineFootprint[]> {
|
||||
const conditions = [
|
||||
gte(timelineFootprints.recordedAt, filters.dayStartMs),
|
||||
lte(timelineFootprints.recordedAt, filters.dayEndMs),
|
||||
];
|
||||
if (filters.employeeId) {
|
||||
conditions.push(eq(timelineFootprints.employeeId, filters.employeeId));
|
||||
}
|
||||
|
||||
const rows = await this.db
|
||||
.select({
|
||||
footprint: timelineFootprints,
|
||||
employee: employees,
|
||||
})
|
||||
.from(timelineFootprints)
|
||||
.innerJoin(employees, eq(timelineFootprints.employeeId, employees.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(timelineFootprints.recordedAt);
|
||||
|
||||
return rows.map((row) => this.toDomain(row.footprint, row.employee));
|
||||
}
|
||||
|
||||
private toDomain(
|
||||
row: TimelineFootprintRow,
|
||||
employee?: typeof employees.$inferSelect,
|
||||
): TimelineFootprint {
|
||||
return {
|
||||
id: row.id,
|
||||
employeeId: row.employeeId,
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
recordedAt: row.recordedAt,
|
||||
...(employee
|
||||
? {
|
||||
employee: {
|
||||
id: employee.id,
|
||||
code: employee.code,
|
||||
name: employee.name,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiForbiddenResponse,
|
||||
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 {
|
||||
ADMIN_SALES_TIMELINE_PRIVILEGE_KEY,
|
||||
FIELD_ATTENDANCE_PRIVILEGE_KEY,
|
||||
MOBILE_SALES_TIMELINE_PRIVILEGE_KEY,
|
||||
} from '../shared/field-purpose';
|
||||
import {
|
||||
ListTimelineQueryDto,
|
||||
TimelineConfigDto,
|
||||
TimelineDayDto,
|
||||
TimelineMeDto,
|
||||
} from './dto/timeline.dto';
|
||||
import { TimelineService } from './timeline.service';
|
||||
|
||||
@ApiTags('timeline')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('timeline')
|
||||
export class TimelineReadController {
|
||||
constructor(private readonly timelineService: TimelineService) {}
|
||||
|
||||
@Get('config')
|
||||
@RequirePrivilege(
|
||||
[MOBILE_SALES_TIMELINE_PRIVILEGE_KEY, FIELD_ATTENDANCE_PRIVILEGE_KEY],
|
||||
'view',
|
||||
)
|
||||
@ApiOperation({ summary: 'Get timeline tracking configuration' })
|
||||
@ApiOkResponse({ type: TimelineConfigDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
config(): Promise<TimelineConfigDto> {
|
||||
return this.timelineService.getConfig();
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@RequirePrivilege(MOBILE_SALES_TIMELINE_PRIVILEGE_KEY, 'view')
|
||||
@ApiOperation({ summary: 'Get current user timeline activities for a day' })
|
||||
@ApiOkResponse({ type: TimelineMeDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
me(
|
||||
@Query() query: ListTimelineQueryDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<TimelineMeDto> {
|
||||
return this.timelineService.getMyDay(query, userId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePrivilege(ADMIN_SALES_TIMELINE_PRIVILEGE_KEY, 'view')
|
||||
@ApiOperation({ summary: 'Get timeline footprints and activities for a day' })
|
||||
@ApiOkResponse({ type: TimelineDayDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
day(@Query() query: ListTimelineQueryDto): Promise<TimelineDayDto> {
|
||||
return this.timelineService.getDay(query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiCreatedResponse,
|
||||
ApiForbiddenResponse,
|
||||
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 { MOBILE_SALES_TIMELINE_PRIVILEGE_KEY } from '../shared/field-purpose';
|
||||
import {
|
||||
IngestTimelineFootprintsDto,
|
||||
IngestTimelineFootprintsResultDto,
|
||||
} from './dto/timeline.dto';
|
||||
import { TimelineService } from './timeline.service';
|
||||
|
||||
@ApiTags('timeline')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('timeline')
|
||||
export class TimelineWriteController {
|
||||
constructor(private readonly timelineService: TimelineService) {}
|
||||
|
||||
@Post('footprints')
|
||||
@RequirePrivilege(MOBILE_SALES_TIMELINE_PRIVILEGE_KEY, 'create')
|
||||
@ApiOperation({ summary: 'Ingest GPS footprint points' })
|
||||
@ApiCreatedResponse({ type: IngestTimelineFootprintsResultDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
ingestFootprints(
|
||||
@Body() dto: IngestTimelineFootprintsDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<IngestTimelineFootprintsResultDto> {
|
||||
return this.timelineService.ingestFootprints(dto, userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { EmployeesModule } from '../../configuration/employees/employees.module';
|
||||
import { CompanySettingsRepository } from '../settings/company-settings.repository';
|
||||
import { CompanySettingsService } from '../settings/company-settings.service';
|
||||
import { TimelineActivitiesRepository } from './timeline-activities.repository';
|
||||
import { TimelineActivitiesService } from './timeline-activities.service';
|
||||
import { TimelineFootprintsRepository } from './timeline-footprints.repository';
|
||||
import { TimelineReadController } from './timeline-read.controller';
|
||||
import { TimelineWriteController } from './timeline-write.controller';
|
||||
import { TimelineService } from './timeline.service';
|
||||
|
||||
@Module({
|
||||
imports: [EmployeesModule],
|
||||
controllers: [TimelineReadController, TimelineWriteController],
|
||||
providers: [
|
||||
CompanySettingsRepository,
|
||||
CompanySettingsService,
|
||||
TimelineFootprintsRepository,
|
||||
TimelineActivitiesRepository,
|
||||
TimelineActivitiesService,
|
||||
TimelineService,
|
||||
],
|
||||
exports: [TimelineActivitiesService, TimelineService],
|
||||
})
|
||||
export class TimelineModule {}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
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 { EmployeesService } from '../../configuration/employees/employees.service';
|
||||
import type { CompanySetting } from '../settings/company-setting';
|
||||
import { CompanySettingsService } from '../settings/company-settings.service';
|
||||
import { TimelineActivitiesRepository } from './timeline-activities.repository';
|
||||
import { TimelineFootprintsRepository } from './timeline-footprints.repository';
|
||||
import { TimelineService } from './timeline.service';
|
||||
|
||||
describe('TimelineService', () => {
|
||||
let service: TimelineService;
|
||||
let footprintsRepository: jest.Mocked<
|
||||
Pick<TimelineFootprintsRepository, 'insertMany' | 'list'>
|
||||
>;
|
||||
let activitiesRepository: jest.Mocked<Pick<TimelineActivitiesRepository, 'list'>>;
|
||||
let employeesService: jest.Mocked<Pick<EmployeesService, 'requireByUserId'>>;
|
||||
let companySettingsService: jest.Mocked<
|
||||
Pick<CompanySettingsService, 'requireTimelineConfig'>
|
||||
>;
|
||||
|
||||
const nowMs = DateTime.fromUnixMs(Date.now()).startOfDay().value + 3_600_000;
|
||||
|
||||
beforeEach(async () => {
|
||||
footprintsRepository = {
|
||||
insertMany: jest.fn(),
|
||||
list: jest.fn(),
|
||||
};
|
||||
activitiesRepository = {
|
||||
list: jest.fn(),
|
||||
};
|
||||
employeesService = {
|
||||
requireByUserId: jest.fn(),
|
||||
};
|
||||
companySettingsService = {
|
||||
requireTimelineConfig: jest.fn(),
|
||||
};
|
||||
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
TimelineService,
|
||||
{ provide: TimelineFootprintsRepository, useValue: footprintsRepository },
|
||||
{ provide: TimelineActivitiesRepository, useValue: activitiesRepository },
|
||||
{ provide: EmployeesService, useValue: employeesService },
|
||||
{ provide: CompanySettingsService, useValue: companySettingsService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = moduleRef.get(TimelineService);
|
||||
companySettingsService.requireTimelineConfig.mockResolvedValue({
|
||||
gpsIntervalSeconds: 5,
|
||||
checkoutWarningRadiusMeters: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns timeline config from company settings', async () => {
|
||||
await expect(service.getConfig()).resolves.toEqual({
|
||||
gpsIntervalSeconds: 5,
|
||||
checkoutWarningRadiusMeters: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it('ingests footprint points for the current employee', async () => {
|
||||
employeesService.requireByUserId.mockResolvedValue({
|
||||
id: 'emp-1',
|
||||
} as Awaited<ReturnType<EmployeesService['requireByUserId']>>);
|
||||
footprintsRepository.insertMany.mockResolvedValue(1);
|
||||
|
||||
const result = await service.ingestFootprints(
|
||||
{
|
||||
points: [{ latitude: -6.2, longitude: 106.8, recordedAt: nowMs }],
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(result).toEqual({ inserted: 1 });
|
||||
expect(footprintsRepository.insertMany).toHaveBeenCalledWith('emp-1', [
|
||||
{ latitude: -6.2, longitude: 106.8, recordedAt: nowMs },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects stale footprint timestamps', async () => {
|
||||
employeesService.requireByUserId.mockResolvedValue({
|
||||
id: 'emp-1',
|
||||
} as Awaited<ReturnType<EmployeesService['requireByUserId']>>);
|
||||
|
||||
await expect(
|
||||
service.ingestFootprints(
|
||||
{
|
||||
points: [
|
||||
{
|
||||
latitude: -6.2,
|
||||
longitude: 106.8,
|
||||
recordedAt: Date.now() - 25 * 60 * 60 * 1000,
|
||||
},
|
||||
],
|
||||
},
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('downsamples footprints when querying all employees', async () => {
|
||||
const footprints = Array.from({ length: 4000 }, (_, index) => ({
|
||||
id: `fp-${index}`,
|
||||
employeeId: 'emp-1',
|
||||
latitude: -6.2,
|
||||
longitude: 106.8 + index * 0.0001,
|
||||
recordedAt: nowMs + index * 1000,
|
||||
employee: { id: 'emp-1', code: 'E1', name: 'Ada' },
|
||||
}));
|
||||
footprintsRepository.list.mockResolvedValue(footprints);
|
||||
activitiesRepository.list.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getDay({ date: '2026-09-01' });
|
||||
|
||||
expect(result.footprints.length).toBeLessThanOrEqual(2000);
|
||||
expect(result.activities).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
DEFAULT_RELATION_FIELDS,
|
||||
pickRelation,
|
||||
} from '../../../common/http/response';
|
||||
import { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { EmployeesService } from '../../configuration/employees/employees.service';
|
||||
import { CompanySettingsService } from '../settings/company-settings.service';
|
||||
import type {
|
||||
IngestTimelineFootprintsDto,
|
||||
ListTimelineQueryDto,
|
||||
TimelineActivityDto,
|
||||
TimelineConfigDto,
|
||||
TimelineDayDto,
|
||||
TimelineFootprintDto,
|
||||
TimelineMeDto,
|
||||
} from './dto/timeline.dto';
|
||||
import { TimelineActivitiesRepository } from './timeline-activities.repository';
|
||||
import { TimelineFootprintsRepository } from './timeline-footprints.repository';
|
||||
import type { TimelineActivity, TimelineFootprint } from './timeline.types';
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
const MAX_FOOTPRINTS_ALL_EMPLOYEES = 2000;
|
||||
const MAX_FOOTPRINT_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class TimelineService {
|
||||
constructor(
|
||||
private readonly timelineFootprintsRepository: TimelineFootprintsRepository,
|
||||
private readonly timelineActivitiesRepository: TimelineActivitiesRepository,
|
||||
private readonly employeesService: EmployeesService,
|
||||
private readonly companySettingsService: CompanySettingsService,
|
||||
) {}
|
||||
|
||||
async getConfig(): Promise<TimelineConfigDto> {
|
||||
return this.companySettingsService.requireTimelineConfig();
|
||||
}
|
||||
|
||||
async ingestFootprints(
|
||||
dto: IngestTimelineFootprintsDto,
|
||||
userId: string,
|
||||
): Promise<{ inserted: number }> {
|
||||
const employee = await this.employeesService.requireByUserId(userId);
|
||||
const now = Date.now();
|
||||
const points = dto.points.map((point) => {
|
||||
this.assertCoordinates(point.latitude, point.longitude);
|
||||
if (now - point.recordedAt > MAX_FOOTPRINT_AGE_MS) {
|
||||
throw new BadRequestException('Footprint timestamp is too old');
|
||||
}
|
||||
if (point.recordedAt > now + 60_000) {
|
||||
throw new BadRequestException('Footprint timestamp is in the future');
|
||||
}
|
||||
return point;
|
||||
});
|
||||
|
||||
const inserted = await this.timelineFootprintsRepository.insertMany(
|
||||
employee.id,
|
||||
points,
|
||||
);
|
||||
return { inserted };
|
||||
}
|
||||
|
||||
async getDay(query: ListTimelineQueryDto): Promise<TimelineDayDto> {
|
||||
const { dateLabel, filters } = this.resolveDayFilters(query);
|
||||
const [footprints, activities] = await Promise.all([
|
||||
this.timelineFootprintsRepository.list(filters),
|
||||
this.timelineActivitiesRepository.list(filters),
|
||||
]);
|
||||
|
||||
return {
|
||||
date: dateLabel,
|
||||
footprints: this.downsampleFootprints(footprints, query.employeeId).map(
|
||||
(item) => this.toFootprintDto(item),
|
||||
),
|
||||
activities: activities.map((item) => this.toActivityDto(item)),
|
||||
};
|
||||
}
|
||||
|
||||
async getMyDay(
|
||||
query: ListTimelineQueryDto,
|
||||
userId: string,
|
||||
): Promise<TimelineMeDto> {
|
||||
const employee = await this.employeesService.requireByUserId(userId);
|
||||
const { dateLabel, filters } = this.resolveDayFilters({
|
||||
...query,
|
||||
employeeId: employee.id,
|
||||
});
|
||||
const activities = await this.timelineActivitiesRepository.list(filters);
|
||||
return {
|
||||
date: dateLabel,
|
||||
activities: activities.map((item) => this.toActivityDto(item)),
|
||||
};
|
||||
}
|
||||
|
||||
private resolveDayFilters(query: ListTimelineQueryDto): {
|
||||
dateLabel: string;
|
||||
filters: {
|
||||
dayStartMs: number;
|
||||
dayEndMs: number;
|
||||
employeeId?: string;
|
||||
};
|
||||
} {
|
||||
const dateLabel = this.resolveDateLabel(query.date);
|
||||
const dayStart = DateTime.create(dateLabel).startOfDay();
|
||||
return {
|
||||
dateLabel,
|
||||
filters: {
|
||||
dayStartMs: dayStart.value,
|
||||
dayEndMs: dayStart.value + MS_PER_DAY - 1,
|
||||
employeeId: query.employeeId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private resolveDateLabel(raw?: string): string {
|
||||
if (!raw) {
|
||||
return DateTime.fromUnixMs(Date.now()).startOfDay().format().slice(0, 10);
|
||||
}
|
||||
try {
|
||||
DateTime.create(raw);
|
||||
return raw;
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidDateTimeError) {
|
||||
throw new BadRequestException('Invalid date');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private downsampleFootprints(
|
||||
footprints: readonly TimelineFootprint[],
|
||||
employeeId?: string,
|
||||
): TimelineFootprint[] {
|
||||
if (employeeId || footprints.length <= MAX_FOOTPRINTS_ALL_EMPLOYEES) {
|
||||
return [...footprints];
|
||||
}
|
||||
const stride = Math.ceil(
|
||||
footprints.length / MAX_FOOTPRINTS_ALL_EMPLOYEES,
|
||||
);
|
||||
return footprints.filter((_, index) => index % stride === 0);
|
||||
}
|
||||
|
||||
private assertCoordinates(latitude: number, longitude: number): void {
|
||||
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
|
||||
throw new BadRequestException('Invalid coordinates');
|
||||
}
|
||||
}
|
||||
|
||||
private toFootprintDto(footprint: TimelineFootprint): TimelineFootprintDto {
|
||||
return {
|
||||
id: footprint.id,
|
||||
employee: pickRelation(footprint.employee, DEFAULT_RELATION_FIELDS)!,
|
||||
latitude: footprint.latitude,
|
||||
longitude: footprint.longitude,
|
||||
recordedAt: footprint.recordedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private toActivityDto(activity: TimelineActivity): TimelineActivityDto {
|
||||
return {
|
||||
id: activity.id,
|
||||
employee: pickRelation(activity.employee, DEFAULT_RELATION_FIELDS)!,
|
||||
customer: activity.customer
|
||||
? pickRelation(activity.customer, DEFAULT_RELATION_FIELDS)
|
||||
: null,
|
||||
visitId: activity.visitId,
|
||||
type: activity.type,
|
||||
sourceType: activity.sourceType,
|
||||
sourceId: activity.sourceId,
|
||||
latitude: activity.latitude,
|
||||
longitude: activity.longitude,
|
||||
recordedAt: activity.recordedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { TimelineActivityType } from '../../../database/timeline-activities-table';
|
||||
|
||||
export type TimelineFootprint = {
|
||||
readonly id: string;
|
||||
readonly employeeId: string;
|
||||
readonly latitude: number;
|
||||
readonly longitude: number;
|
||||
readonly recordedAt: number;
|
||||
readonly employee?: {
|
||||
readonly id: string;
|
||||
readonly code: string;
|
||||
readonly name: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type TimelineActivity = {
|
||||
readonly id: string;
|
||||
readonly employeeId: string;
|
||||
readonly customerId: string | null;
|
||||
readonly visitId: string | null;
|
||||
readonly type: TimelineActivityType;
|
||||
readonly sourceType: string;
|
||||
readonly sourceId: string;
|
||||
readonly latitude: number;
|
||||
readonly longitude: number;
|
||||
readonly recordedAt: number;
|
||||
readonly employee?: {
|
||||
readonly id: string;
|
||||
readonly code: string;
|
||||
readonly name: string;
|
||||
};
|
||||
readonly customer?: {
|
||||
readonly id: string;
|
||||
readonly code: string;
|
||||
readonly name: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type RecordTimelineActivityInput = {
|
||||
readonly employeeId: string;
|
||||
readonly type: TimelineActivityType;
|
||||
readonly sourceType: string;
|
||||
readonly sourceId: string;
|
||||
readonly latitude: number;
|
||||
readonly longitude: number;
|
||||
readonly recordedAt?: number;
|
||||
readonly customerId?: string | null;
|
||||
readonly visitId?: string | null;
|
||||
};
|
||||
|
||||
export type IngestFootprintPoint = {
|
||||
readonly latitude: number;
|
||||
readonly longitude: number;
|
||||
readonly recordedAt: number;
|
||||
};
|
||||
|
||||
export type ListTimelineFilters = {
|
||||
readonly dayStartMs: number;
|
||||
readonly dayEndMs: number;
|
||||
readonly employeeId?: string;
|
||||
};
|
||||
@@ -0,0 +1,230 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsLatitude,
|
||||
IsLongitude,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
IsUUID,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../../common/http/response';
|
||||
import { CORE_STATUSES } from '../../../../common/value-objects/status/status';
|
||||
import { CHECK_IN_METHODS } from '../../shared/check-in-verification';
|
||||
|
||||
export class VisitCheckInDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID('4')
|
||||
customerId!: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
planId?: string;
|
||||
|
||||
@ApiProperty({ enum: CHECK_IN_METHODS })
|
||||
@IsIn([...CHECK_IN_METHODS])
|
||||
method!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@ValidateIf((dto: VisitCheckInDto) => dto.method === 'nfc')
|
||||
@IsString()
|
||||
nfcId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@ValidateIf((dto: VisitCheckInDto) => dto.method === 'qr')
|
||||
@IsString()
|
||||
qrCode?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsLatitude()
|
||||
latitude!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsLongitude()
|
||||
longitude!: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsUrl({ require_protocol: true, protocols: ['https'] })
|
||||
photoUrl?: string;
|
||||
}
|
||||
|
||||
export class VisitCheckOutDto {
|
||||
@ApiProperty({ enum: CHECK_IN_METHODS })
|
||||
@IsIn([...CHECK_IN_METHODS])
|
||||
method!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@ValidateIf((dto: VisitCheckOutDto) => dto.method === 'nfc')
|
||||
@IsString()
|
||||
nfcId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@ValidateIf((dto: VisitCheckOutDto) => dto.method === 'qr')
|
||||
@IsString()
|
||||
qrCode?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsLatitude()
|
||||
latitude!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsLongitude()
|
||||
longitude!: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsUrl({ require_protocol: true, protocols: ['https'] })
|
||||
photoUrl?: string;
|
||||
}
|
||||
|
||||
export class UpdateVisitStatusDto {
|
||||
@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 ListVisitsQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
employeeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
customerId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Unix ms start of calendar day' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
date?: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: CORE_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...CORE_STATUSES])
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
}
|
||||
|
||||
class RelationDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
code!: string;
|
||||
|
||||
@ApiProperty()
|
||||
name!: string;
|
||||
}
|
||||
|
||||
class UserRelationDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
username!: string;
|
||||
}
|
||||
|
||||
export class VisitDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ type: RelationDto })
|
||||
employee!: RelationDto;
|
||||
|
||||
@ApiProperty({ type: RelationDto })
|
||||
customer!: RelationDto;
|
||||
|
||||
@ApiProperty({ format: 'uuid', nullable: true })
|
||||
attendanceId!: string | null;
|
||||
|
||||
@ApiProperty({ format: 'uuid', nullable: true })
|
||||
planId!: string | null;
|
||||
|
||||
@ApiProperty({ format: 'uuid', nullable: true })
|
||||
planDestinationId!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
date!: number;
|
||||
|
||||
@ApiProperty()
|
||||
checkInAt!: number;
|
||||
|
||||
@ApiProperty({ enum: CHECK_IN_METHODS })
|
||||
checkInMethod!: string;
|
||||
|
||||
@ApiProperty()
|
||||
checkInLatitude!: number;
|
||||
|
||||
@ApiProperty()
|
||||
checkInLongitude!: number;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
checkInPhotoUrl!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
checkInDistanceMeters!: number | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
checkOutAt!: number | null;
|
||||
|
||||
@ApiProperty({ enum: CHECK_IN_METHODS, nullable: true })
|
||||
checkOutMethod!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
checkOutLatitude!: number | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
checkOutLongitude!: number | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
checkOutPhotoUrl!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
checkOutDistanceMeters!: number | null;
|
||||
|
||||
@ApiProperty()
|
||||
status!: string;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt!: number;
|
||||
|
||||
@ApiProperty()
|
||||
updatedAt!: number;
|
||||
|
||||
@ApiProperty({ type: UserRelationDto })
|
||||
createdBy!: UserRelationDto;
|
||||
|
||||
@ApiProperty({ type: UserRelationDto })
|
||||
updatedBy!: UserRelationDto;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import type { CheckInMethod } from '../shared/check-in-verification';
|
||||
|
||||
export type Visit = {
|
||||
readonly id: string;
|
||||
readonly employeeId: string;
|
||||
readonly customerId: string;
|
||||
readonly attendanceId: string | null;
|
||||
readonly planId: string | null;
|
||||
readonly planDestinationId: string | null;
|
||||
readonly date: DateTime;
|
||||
readonly checkInAt: DateTime;
|
||||
readonly checkInMethod: CheckInMethod;
|
||||
readonly checkInLatitude: number;
|
||||
readonly checkInLongitude: number;
|
||||
readonly checkInPhotoUrl: string | null;
|
||||
readonly checkInDistanceMeters: number | null;
|
||||
readonly checkOutAt: DateTime | null;
|
||||
readonly checkOutMethod: CheckInMethod | null;
|
||||
readonly checkOutLatitude: number | null;
|
||||
readonly checkOutLongitude: number | null;
|
||||
readonly checkOutPhotoUrl: string | null;
|
||||
readonly checkOutDistanceMeters: number | null;
|
||||
readonly status: Status;
|
||||
readonly createdAt: DateTime;
|
||||
readonly updatedAt: DateTime;
|
||||
readonly createdBy: string;
|
||||
readonly updatedBy: string;
|
||||
readonly employee: { id: string; code: string; name: string } | null;
|
||||
readonly customer: { id: string; code: string; name: string } | null;
|
||||
readonly createdByUser: { id: string; username: string } | null;
|
||||
readonly updatedByUser: { id: string; username: string } | null;
|
||||
};
|
||||
|
||||
export type ListVisitsFilters = {
|
||||
readonly employeeId?: string;
|
||||
readonly customerId?: string;
|
||||
readonly date?: number;
|
||||
readonly status?: string;
|
||||
readonly search?: string;
|
||||
readonly orderBy?: string;
|
||||
readonly orderType?: string;
|
||||
readonly limit: number;
|
||||
readonly offset: number;
|
||||
};
|
||||
|
||||
export type CreateVisitInput = {
|
||||
readonly employeeId: string;
|
||||
readonly customerId: string;
|
||||
readonly attendanceId: string | null;
|
||||
readonly planId: string | null;
|
||||
readonly planDestinationId: string | null;
|
||||
readonly date: DateTime;
|
||||
readonly checkInAt: DateTime;
|
||||
readonly checkInMethod: CheckInMethod;
|
||||
readonly checkInLatitude: number;
|
||||
readonly checkInLongitude: number;
|
||||
readonly checkInPhotoUrl: string | null;
|
||||
readonly checkInDistanceMeters: number | null;
|
||||
readonly userId: string;
|
||||
};
|
||||
|
||||
export type CheckOutVisitInput = {
|
||||
readonly checkOutAt: DateTime;
|
||||
readonly checkOutMethod: CheckInMethod;
|
||||
readonly checkOutLatitude: number;
|
||||
readonly checkOutLongitude: number;
|
||||
readonly checkOutPhotoUrl: string | null;
|
||||
readonly checkOutDistanceMeters: number | null;
|
||||
readonly userId: string;
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiForbiddenResponse,
|
||||
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 {
|
||||
Pagination,
|
||||
type PaginationResponse,
|
||||
PaginationMetaDto,
|
||||
} from '../../../common/http/response';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import { FIELD_VISIT_PRIVILEGE_KEY } from '../shared/field-purpose';
|
||||
import { ListVisitsQueryDto, VisitDto } from './dto/visit.dto';
|
||||
import { VisitsService } from './visits.service';
|
||||
|
||||
@ApiTags('visits')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('visits')
|
||||
export class VisitsReadController {
|
||||
constructor(private readonly visitsService: VisitsService) {}
|
||||
|
||||
@Get('current')
|
||||
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'view')
|
||||
@ApiOperation({ summary: 'Get the open visit for the current user' })
|
||||
@ApiOkResponse({ type: VisitDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
current(@CurrentUser('id') userId: string): Promise<VisitDto | null> {
|
||||
return this.visitsService.findCurrent(userId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Pagination()
|
||||
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'view')
|
||||
@ApiOperation({ summary: 'List visits' })
|
||||
@ApiOkResponse({
|
||||
schema: {
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/VisitDto' },
|
||||
},
|
||||
meta: { $ref: '#/components/schemas/PaginationMetaDto' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
list(
|
||||
@Query() query: ListVisitsQueryDto,
|
||||
): Promise<PaginationResponse<VisitDto>> {
|
||||
return this.visitsService.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'view')
|
||||
@ApiOperation({ summary: 'Get visit detail' })
|
||||
@ApiOkResponse({ type: VisitDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<VisitDto> {
|
||||
return this.visitsService.findById(id);
|
||||
}
|
||||
}
|
||||
|
||||
void PaginationMetaDto;
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
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 { FIELD_VISIT_PRIVILEGE_KEY } from '../shared/field-purpose';
|
||||
import {
|
||||
BulkIdsDto,
|
||||
BulkStatusDto,
|
||||
UpdateVisitStatusDto,
|
||||
VisitCheckInDto,
|
||||
VisitCheckOutDto,
|
||||
VisitDto,
|
||||
} from './dto/visit.dto';
|
||||
import { VisitsService } from './visits.service';
|
||||
|
||||
@ApiTags('visits')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('visits')
|
||||
export class VisitsWriteController {
|
||||
constructor(private readonly visitsService: VisitsService) {}
|
||||
|
||||
@Post('check-in')
|
||||
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'create')
|
||||
@ApiOperation({ summary: 'Check in at a customer location' })
|
||||
@ApiCreatedResponse({ type: VisitDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
checkIn(
|
||||
@Body() dto: VisitCheckInDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<VisitDto> {
|
||||
return this.visitsService.checkIn(dto, userId);
|
||||
}
|
||||
|
||||
@Post('bulk-delete')
|
||||
@HttpCode(200)
|
||||
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'delete')
|
||||
@ApiOperation({ summary: 'Bulk delete visits' })
|
||||
@ApiOkResponse({ schema: { properties: { deleted: { type: 'number' } } } })
|
||||
bulkDelete(@Body() dto: BulkIdsDto): Promise<{ deleted: number }> {
|
||||
return this.visitsService.bulkDelete(dto.ids);
|
||||
}
|
||||
|
||||
@Post('bulk-status')
|
||||
@HttpCode(200)
|
||||
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Bulk update visit status' })
|
||||
@ApiOkResponse({ schema: { properties: { updated: { type: 'number' } } } })
|
||||
bulkStatus(
|
||||
@Body() dto: BulkStatusDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<{ updated: number }> {
|
||||
return this.visitsService.bulkUpdateStatus(dto.ids, dto.status, userId);
|
||||
}
|
||||
|
||||
@Post(':id/check-out')
|
||||
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Check out from a customer visit' })
|
||||
@ApiOkResponse({ type: VisitDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
checkOut(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: VisitCheckOutDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<VisitDto> {
|
||||
return this.visitsService.checkOut(id, dto, userId);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Update visit status' })
|
||||
@ApiOkResponse({ type: VisitDto })
|
||||
updateStatus(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateVisitStatusDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<VisitDto> {
|
||||
return this.visitsService.updateStatus(id, dto.status, userId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'delete')
|
||||
@ApiOperation({ summary: 'Delete visit' })
|
||||
@ApiNoContentResponse()
|
||||
@ApiNotFoundResponse()
|
||||
delete(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||
return this.visitsService.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { and, count, eq, ilike, inArray, isNull, or, SQL } from 'drizzle-orm';
|
||||
import { alias } from 'drizzle-orm/pg-core';
|
||||
import { toOrderClauses } from '../../../common/http/response/order-clause';
|
||||
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 { customers } from '../../../database/customers-table';
|
||||
import { planDestinations } from '../../../database/plans-table';
|
||||
import { visits, type VisitRow } from '../../../database/visits-table';
|
||||
import { employees, users } from '../../../database/schema';
|
||||
import type {
|
||||
CheckOutVisitInput,
|
||||
CreateVisitInput,
|
||||
ListVisitsFilters,
|
||||
Visit,
|
||||
} from './visit';
|
||||
import type { CheckInMethod } from '../shared/check-in-verification';
|
||||
|
||||
const VISIT_ORDER_COLUMNS = {
|
||||
id: visits.id,
|
||||
date: visits.date,
|
||||
status: visits.status,
|
||||
createdAt: visits.createdAt,
|
||||
updatedAt: visits.updatedAt,
|
||||
};
|
||||
|
||||
const createdByUsers = alias(users, 'visit_created_by_users');
|
||||
const updatedByUsers = alias(users, 'visit_updated_by_users');
|
||||
|
||||
type VisitJoinedRow = {
|
||||
visit: VisitRow;
|
||||
employee: typeof employees.$inferSelect;
|
||||
customer: typeof customers.$inferSelect;
|
||||
createdByUser: typeof users.$inferSelect | null;
|
||||
updatedByUser: typeof users.$inferSelect | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class VisitsRepository {
|
||||
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||
|
||||
async list(
|
||||
filters: ListVisitsFilters,
|
||||
): Promise<{ data: Visit[]; total: number }> {
|
||||
const where = this.buildListWhere(filters);
|
||||
const totalRows = await this.db
|
||||
.select({ total: count() })
|
||||
.from(visits)
|
||||
.where(where);
|
||||
const totalRow = totalRows[0];
|
||||
|
||||
const rows = await this.selectWithRelations()
|
||||
.where(where)
|
||||
.orderBy(
|
||||
...toOrderClauses(VISIT_ORDER_COLUMNS, filters, [
|
||||
{ column: 'date', type: 'DESC' },
|
||||
]),
|
||||
)
|
||||
.limit(filters.limit)
|
||||
.offset(filters.offset);
|
||||
|
||||
return {
|
||||
data: rows.map((row) => this.toDomain(row)),
|
||||
total: Number(totalRow?.total ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Visit | null> {
|
||||
const rows = await this.selectWithRelations()
|
||||
.where(eq(visits.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
|
||||
async findOpenByEmployeeId(employeeId: string): Promise<Visit | null> {
|
||||
const rows = await this.selectWithRelations()
|
||||
.where(and(eq(visits.employeeId, employeeId), isNull(visits.checkOutAt)))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
|
||||
async findPlanDestinationId(
|
||||
planId: string,
|
||||
customerId: string,
|
||||
): Promise<string | null> {
|
||||
const rows = await this.db
|
||||
.select({ id: planDestinations.id })
|
||||
.from(planDestinations)
|
||||
.where(
|
||||
and(
|
||||
eq(planDestinations.planId, planId),
|
||||
eq(planDestinations.customerId, customerId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0]?.id ?? null;
|
||||
}
|
||||
|
||||
async create(input: CreateVisitInput): Promise<Visit> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
try {
|
||||
const inserted = await this.db
|
||||
.insert(visits)
|
||||
.values({
|
||||
employeeId: input.employeeId,
|
||||
customerId: input.customerId,
|
||||
attendanceId: input.attendanceId,
|
||||
planId: input.planId,
|
||||
planDestinationId: input.planDestinationId,
|
||||
date: input.date.value,
|
||||
checkInAt: input.checkInAt.value,
|
||||
checkInMethod: input.checkInMethod,
|
||||
checkInLatitude: input.checkInLatitude,
|
||||
checkInLongitude: input.checkInLongitude,
|
||||
checkInPhotoUrl: input.checkInPhotoUrl,
|
||||
checkInDistanceMeters: input.checkInDistanceMeters,
|
||||
status: Status.create('active').value,
|
||||
createdAt: now.value,
|
||||
updatedAt: now.value,
|
||||
createdBy: input.userId,
|
||||
updatedBy: input.userId,
|
||||
})
|
||||
.returning();
|
||||
const created = await this.findById(inserted[0].id);
|
||||
if (!created) {
|
||||
throw new NotFoundException('Visit not found');
|
||||
}
|
||||
return created;
|
||||
} catch (error) {
|
||||
this.rethrowUniqueViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async checkOut(id: string, input: CheckOutVisitInput): Promise<Visit> {
|
||||
const existing = await this.findById(id);
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Visit not found');
|
||||
}
|
||||
if (existing.checkOutAt) {
|
||||
throw new ConflictException('Visit is already checked out');
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
await this.db
|
||||
.update(visits)
|
||||
.set({
|
||||
checkOutAt: input.checkOutAt.value,
|
||||
checkOutMethod: input.checkOutMethod,
|
||||
checkOutLatitude: input.checkOutLatitude,
|
||||
checkOutLongitude: input.checkOutLongitude,
|
||||
checkOutPhotoUrl: input.checkOutPhotoUrl,
|
||||
checkOutDistanceMeters: input.checkOutDistanceMeters,
|
||||
updatedAt: now.value,
|
||||
updatedBy: input.userId,
|
||||
})
|
||||
.where(eq(visits.id, id));
|
||||
const updated = await this.findById(id);
|
||||
if (!updated) {
|
||||
throw new NotFoundException('Visit not found');
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
status: Status,
|
||||
userId: string,
|
||||
): Promise<Visit> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const updated = await this.db
|
||||
.update(visits)
|
||||
.set({
|
||||
status: status.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(eq(visits.id, id))
|
||||
.returning({ id: visits.id });
|
||||
if (updated.length === 0) {
|
||||
throw new NotFoundException('Visit not found');
|
||||
}
|
||||
const row = await this.findById(id);
|
||||
if (!row) {
|
||||
throw new NotFoundException('Visit not found');
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
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(visits)
|
||||
.set({
|
||||
status: status.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(inArray(visits.id, ids))
|
||||
.returning({ id: visits.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const deleted = await this.db
|
||||
.delete(visits)
|
||||
.where(eq(visits.id, id))
|
||||
.returning({ id: visits.id });
|
||||
if (deleted.length === 0) {
|
||||
throw new NotFoundException('Visit not found');
|
||||
}
|
||||
}
|
||||
|
||||
async bulkDelete(ids: string[]): Promise<number> {
|
||||
if (ids.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const deleted = await this.db
|
||||
.delete(visits)
|
||||
.where(inArray(visits.id, ids))
|
||||
.returning({ id: visits.id });
|
||||
return deleted.length;
|
||||
}
|
||||
|
||||
private selectWithRelations() {
|
||||
return this.db
|
||||
.select({
|
||||
visit: visits,
|
||||
employee: employees,
|
||||
customer: customers,
|
||||
createdByUser: createdByUsers,
|
||||
updatedByUser: updatedByUsers,
|
||||
})
|
||||
.from(visits)
|
||||
.innerJoin(employees, eq(visits.employeeId, employees.id))
|
||||
.innerJoin(customers, eq(visits.customerId, customers.id))
|
||||
.leftJoin(createdByUsers, eq(visits.createdBy, createdByUsers.id))
|
||||
.leftJoin(updatedByUsers, eq(visits.updatedBy, updatedByUsers.id));
|
||||
}
|
||||
|
||||
private buildListWhere(filters: ListVisitsFilters): SQL | undefined {
|
||||
const parts: SQL[] = [];
|
||||
if (filters.employeeId) {
|
||||
parts.push(eq(visits.employeeId, filters.employeeId));
|
||||
}
|
||||
if (filters.customerId) {
|
||||
parts.push(eq(visits.customerId, filters.customerId));
|
||||
}
|
||||
if (filters.date !== undefined) {
|
||||
parts.push(eq(visits.date, filters.date));
|
||||
}
|
||||
if (filters.status) {
|
||||
parts.push(eq(visits.status, filters.status));
|
||||
}
|
||||
if (filters.search) {
|
||||
const search = or(
|
||||
ilike(employees.code, `%${filters.search}%`),
|
||||
ilike(employees.name, `%${filters.search}%`),
|
||||
ilike(customers.code, `%${filters.search}%`),
|
||||
ilike(customers.name, `%${filters.search}%`),
|
||||
);
|
||||
if (search) {
|
||||
parts.push(search);
|
||||
}
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return parts.length === 1 ? parts[0] : and(...parts);
|
||||
}
|
||||
|
||||
private toDomain(row: VisitJoinedRow): Visit {
|
||||
const visit = row.visit;
|
||||
return {
|
||||
id: visit.id,
|
||||
employeeId: visit.employeeId,
|
||||
customerId: visit.customerId,
|
||||
attendanceId: visit.attendanceId,
|
||||
planId: visit.planId,
|
||||
planDestinationId: visit.planDestinationId,
|
||||
date: DateTime.fromUnixMs(visit.date),
|
||||
checkInAt: DateTime.fromUnixMs(visit.checkInAt),
|
||||
checkInMethod: visit.checkInMethod as CheckInMethod,
|
||||
checkInLatitude: visit.checkInLatitude,
|
||||
checkInLongitude: visit.checkInLongitude,
|
||||
checkInPhotoUrl: visit.checkInPhotoUrl,
|
||||
checkInDistanceMeters: visit.checkInDistanceMeters,
|
||||
checkOutAt: visit.checkOutAt
|
||||
? DateTime.fromUnixMs(visit.checkOutAt)
|
||||
: null,
|
||||
checkOutMethod: visit.checkOutMethod as CheckInMethod | null,
|
||||
checkOutLatitude: visit.checkOutLatitude,
|
||||
checkOutLongitude: visit.checkOutLongitude,
|
||||
checkOutPhotoUrl: visit.checkOutPhotoUrl,
|
||||
checkOutDistanceMeters: visit.checkOutDistanceMeters,
|
||||
status: Status.create(visit.status),
|
||||
createdAt: DateTime.fromUnixMs(visit.createdAt),
|
||||
updatedAt: DateTime.fromUnixMs(visit.updatedAt),
|
||||
createdBy: visit.createdBy,
|
||||
updatedBy: visit.updatedBy,
|
||||
employee: {
|
||||
id: row.employee.id,
|
||||
code: row.employee.code,
|
||||
name: row.employee.name,
|
||||
},
|
||||
customer: {
|
||||
id: row.customer.id,
|
||||
code: row.customer.code,
|
||||
name: row.customer.name,
|
||||
},
|
||||
createdByUser: row.createdByUser
|
||||
? { id: row.createdByUser.id, username: row.createdByUser.username }
|
||||
: null,
|
||||
updatedByUser: row.updatedByUser
|
||||
? { id: row.updatedByUser.id, username: row.updatedByUser.username }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
private rethrowUniqueViolation(error: unknown): never {
|
||||
let current: unknown = error;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (!current || typeof current !== 'object') {
|
||||
break;
|
||||
}
|
||||
const obj = current as { code?: string; cause?: unknown };
|
||||
if (obj.code === '23505') {
|
||||
throw new ConflictException(
|
||||
'A visit is already open for this employee',
|
||||
);
|
||||
}
|
||||
current = obj.cause;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { PaginationResponse } from '../../../common/http/response';
|
||||
import {
|
||||
pickRelation,
|
||||
pickUserRelation,
|
||||
DEFAULT_RELATION_FIELDS,
|
||||
toListPage,
|
||||
} from '../../../common/http/response';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { CustomersService } from '../../configuration/customers/customers.service';
|
||||
import { EmployeesService } from '../../configuration/employees/employees.service';
|
||||
import { AttendancesRepository } from '../attendances/attendances.repository';
|
||||
import { CompanySettingsService } from '../settings/company-settings.service';
|
||||
import {
|
||||
isCheckInMethod,
|
||||
verifyCustomerCheckIn,
|
||||
type CheckInPayload,
|
||||
type CheckInVerificationOptions,
|
||||
} from '../shared/check-in-verification';
|
||||
import { FIELD_VISIT_PRIVILEGE_KEY } from '../shared/field-purpose';
|
||||
import { TimelineActivitiesService } from '../timeline/timeline-activities.service';
|
||||
import type { Visit } from './visit';
|
||||
import type {
|
||||
ListVisitsQueryDto,
|
||||
VisitCheckInDto,
|
||||
VisitCheckOutDto,
|
||||
VisitDto,
|
||||
} from './dto/visit.dto';
|
||||
import { VisitsRepository } from './visits.repository';
|
||||
|
||||
@Injectable()
|
||||
export class VisitsService {
|
||||
constructor(
|
||||
private readonly visitsRepository: VisitsRepository,
|
||||
private readonly attendancesRepository: AttendancesRepository,
|
||||
private readonly customersService: CustomersService,
|
||||
private readonly employeesService: EmployeesService,
|
||||
private readonly companySettingsService: CompanySettingsService,
|
||||
private readonly timelineActivitiesService: TimelineActivitiesService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async list(query: ListVisitsQueryDto): Promise<PaginationResponse<VisitDto>> {
|
||||
const page = toListPage(query);
|
||||
const { data, total } = await this.visitsRepository.list({
|
||||
employeeId: query.employeeId,
|
||||
customerId: query.customerId,
|
||||
date: query.date,
|
||||
status: query.status,
|
||||
search: query.search,
|
||||
orderBy: query.orderBy,
|
||||
orderType: query.orderType,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
});
|
||||
return {
|
||||
data: data.map((item) => this.toItem(item)),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<VisitDto> {
|
||||
const visit = await this.visitsRepository.findById(id);
|
||||
if (!visit) {
|
||||
throw new NotFoundException('Visit not found');
|
||||
}
|
||||
return this.toItem(visit);
|
||||
}
|
||||
|
||||
async findCurrent(userId: string): Promise<VisitDto | null> {
|
||||
const employee = await this.employeesService.requireByUserId(userId);
|
||||
const visit = await this.visitsRepository.findOpenByEmployeeId(employee.id);
|
||||
return visit ? this.toItem(visit) : null;
|
||||
}
|
||||
|
||||
async checkIn(dto: VisitCheckInDto, userId: string): Promise<VisitDto> {
|
||||
const employee = await this.employeesService.requireByUserId(userId);
|
||||
const openVisit = await this.visitsRepository.findOpenByEmployeeId(
|
||||
employee.id,
|
||||
);
|
||||
if (openVisit) {
|
||||
throw new ConflictException('A customer visit is already open');
|
||||
}
|
||||
|
||||
const openAttendance =
|
||||
await this.attendancesRepository.findOpenByEmployeeId(employee.id);
|
||||
if (!openAttendance) {
|
||||
throw new BadRequestException(
|
||||
'Branch check-in is required before visiting a customer',
|
||||
);
|
||||
}
|
||||
|
||||
const customer = await this.customersService.findById(dto.customerId);
|
||||
const radiusMeters =
|
||||
await this.companySettingsService.requireCheckInRadiusMeters();
|
||||
const payload = this.toPayload(dto);
|
||||
const verified = verifyCustomerCheckIn(
|
||||
{
|
||||
code: customer.code,
|
||||
nfcId: customer.nfcId,
|
||||
latitude: customer.latitude,
|
||||
longitude: customer.longitude,
|
||||
},
|
||||
payload,
|
||||
radiusMeters,
|
||||
this.gpsVerificationOptions(),
|
||||
);
|
||||
|
||||
let planDestinationId: string | null = null;
|
||||
if (dto.planId) {
|
||||
planDestinationId = await this.visitsRepository.findPlanDestinationId(
|
||||
dto.planId,
|
||||
dto.customerId,
|
||||
);
|
||||
}
|
||||
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const created = await this.visitsRepository.create({
|
||||
employeeId: employee.id,
|
||||
customerId: dto.customerId,
|
||||
attendanceId: openAttendance.id,
|
||||
planId: dto.planId ?? null,
|
||||
planDestinationId,
|
||||
date: now.startOfDay(),
|
||||
checkInAt: now,
|
||||
checkInMethod: verified.method,
|
||||
checkInLatitude: verified.latitude,
|
||||
checkInLongitude: verified.longitude,
|
||||
checkInPhotoUrl: verified.photoUrl,
|
||||
checkInDistanceMeters: verified.distanceMeters,
|
||||
userId,
|
||||
});
|
||||
await this.timelineActivitiesService.recordIfLocated({
|
||||
employeeId: employee.id,
|
||||
type: 'customer_check_in',
|
||||
sourceType: 'visit',
|
||||
sourceId: created.id,
|
||||
latitude: verified.latitude,
|
||||
longitude: verified.longitude,
|
||||
recordedAt: now.value,
|
||||
customerId: dto.customerId,
|
||||
visitId: created.id,
|
||||
});
|
||||
return this.toItem(created);
|
||||
}
|
||||
|
||||
async checkOut(
|
||||
id: string,
|
||||
dto: VisitCheckOutDto,
|
||||
userId: string,
|
||||
): Promise<VisitDto> {
|
||||
const employee = await this.employeesService.requireByUserId(userId);
|
||||
const visit = await this.visitsRepository.findById(id);
|
||||
if (!visit) {
|
||||
throw new NotFoundException('Visit not found');
|
||||
}
|
||||
if (visit.employeeId !== employee.id) {
|
||||
throw new BadRequestException('Visit does not belong to this user');
|
||||
}
|
||||
if (visit.checkOutAt) {
|
||||
throw new ConflictException('Visit is already checked out');
|
||||
}
|
||||
|
||||
const customer = await this.customersService.findById(visit.customerId);
|
||||
const radiusMeters =
|
||||
await this.companySettingsService.requireCheckInRadiusMeters();
|
||||
const payload = this.toPayload(dto);
|
||||
const verified = verifyCustomerCheckIn(
|
||||
{
|
||||
code: customer.code,
|
||||
nfcId: customer.nfcId,
|
||||
latitude: customer.latitude,
|
||||
longitude: customer.longitude,
|
||||
},
|
||||
payload,
|
||||
radiusMeters,
|
||||
this.gpsVerificationOptions(),
|
||||
);
|
||||
|
||||
const updated = await this.visitsRepository.checkOut(id, {
|
||||
checkOutAt: DateTime.fromUnixMs(Date.now()),
|
||||
checkOutMethod: verified.method,
|
||||
checkOutLatitude: verified.latitude,
|
||||
checkOutLongitude: verified.longitude,
|
||||
checkOutPhotoUrl: verified.photoUrl,
|
||||
checkOutDistanceMeters: verified.distanceMeters,
|
||||
userId,
|
||||
});
|
||||
await this.timelineActivitiesService.recordIfLocated({
|
||||
employeeId: employee.id,
|
||||
type: 'customer_check_out',
|
||||
sourceType: 'visit',
|
||||
sourceId: updated.id,
|
||||
latitude: verified.latitude,
|
||||
longitude: verified.longitude,
|
||||
recordedAt: updated.checkOutAt?.value,
|
||||
customerId: visit.customerId,
|
||||
visitId: updated.id,
|
||||
});
|
||||
return this.toItem(updated);
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
statusRaw: string,
|
||||
userId: string,
|
||||
): Promise<VisitDto> {
|
||||
const status = Status.create(statusRaw);
|
||||
const updated = await this.visitsRepository.updateStatus(
|
||||
id,
|
||||
status,
|
||||
userId,
|
||||
);
|
||||
return this.toItem(updated);
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
ids: string[],
|
||||
statusRaw: string,
|
||||
userId: string,
|
||||
): Promise<{ updated: number }> {
|
||||
const status = Status.create(statusRaw);
|
||||
const updated = await this.visitsRepository.bulkUpdateStatus(
|
||||
ids,
|
||||
status,
|
||||
userId,
|
||||
);
|
||||
return { updated };
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.visitsRepository.delete(id);
|
||||
}
|
||||
|
||||
async bulkDelete(ids: string[]): Promise<{ deleted: number }> {
|
||||
const deleted = await this.visitsRepository.bulkDelete(ids);
|
||||
return { deleted };
|
||||
}
|
||||
|
||||
toItem(visit: Visit): VisitDto {
|
||||
return {
|
||||
id: visit.id,
|
||||
employee: pickRelation(visit.employee, DEFAULT_RELATION_FIELDS)!,
|
||||
customer: pickRelation(visit.customer, DEFAULT_RELATION_FIELDS)!,
|
||||
attendanceId: visit.attendanceId,
|
||||
planId: visit.planId,
|
||||
planDestinationId: visit.planDestinationId,
|
||||
date: visit.date.value,
|
||||
checkInAt: visit.checkInAt.value,
|
||||
checkInMethod: visit.checkInMethod,
|
||||
checkInLatitude: visit.checkInLatitude,
|
||||
checkInLongitude: visit.checkInLongitude,
|
||||
checkInPhotoUrl: visit.checkInPhotoUrl,
|
||||
checkInDistanceMeters: visit.checkInDistanceMeters,
|
||||
checkOutAt: visit.checkOutAt?.value ?? null,
|
||||
checkOutMethod: visit.checkOutMethod,
|
||||
checkOutLatitude: visit.checkOutLatitude,
|
||||
checkOutLongitude: visit.checkOutLongitude,
|
||||
checkOutPhotoUrl: visit.checkOutPhotoUrl,
|
||||
checkOutDistanceMeters: visit.checkOutDistanceMeters,
|
||||
status: visit.status.value,
|
||||
createdAt: visit.createdAt.value,
|
||||
updatedAt: visit.updatedAt.value,
|
||||
createdBy: pickUserRelation(
|
||||
visit.createdByUser ?? { id: visit.createdBy, username: '' },
|
||||
),
|
||||
updatedBy: pickUserRelation(
|
||||
visit.updatedByUser ?? { id: visit.updatedBy, username: '' },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private gpsVerificationOptions(): CheckInVerificationOptions {
|
||||
return {
|
||||
skipGpsValidation:
|
||||
this.config.get<boolean>('SKIP_GPS_VALIDATION') === true,
|
||||
};
|
||||
}
|
||||
|
||||
private toPayload(dto: VisitCheckInDto | VisitCheckOutDto): CheckInPayload {
|
||||
if (!isCheckInMethod(dto.method)) {
|
||||
throw new BadRequestException('Invalid check-in method');
|
||||
}
|
||||
return {
|
||||
method: dto.method,
|
||||
nfcId: dto.nfcId,
|
||||
qrCode: dto.qrCode,
|
||||
latitude: dto.latitude,
|
||||
longitude: dto.longitude,
|
||||
photoUrl: dto.photoUrl,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
void FIELD_VISIT_PRIVILEGE_KEY;
|
||||
@@ -146,7 +146,7 @@ export class PrivilegeDetailDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
privilegeKeyId!: string;
|
||||
|
||||
@ApiProperty({ example: 'SALES.INVOICE' })
|
||||
@ApiProperty({ example: 'ADMIN.SALES.ACTIVITIES.INVOICE' })
|
||||
keyCode!: string;
|
||||
|
||||
@ApiProperty({ example: 'Sales Invoice' })
|
||||
@@ -197,7 +197,7 @@ export class PrivilegeKeyDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: 'PRIVILEGES' })
|
||||
@ApiProperty({ example: 'ADMIN.SETTINGS.USER.PRIVILEGES' })
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ example: 'Privileges' })
|
||||
|
||||
@@ -17,14 +17,15 @@ describe('privilege-action', () => {
|
||||
});
|
||||
|
||||
describe('privilege-key-code', () => {
|
||||
it('accepts dotted uppercase module levels', () => {
|
||||
expect(isValidPrivilegeKeyCode('PRIVILEGES')).toBe(true);
|
||||
expect(isValidPrivilegeKeyCode('SALES.INVOICE')).toBe(true);
|
||||
expect(isValidPrivilegeKeyCode('SALES.INVOICE.LINE')).toBe(true);
|
||||
expect(assertPrivilegeKeyCode('USERS')).toBe('USERS');
|
||||
it('accepts 3- and 4-part dotted uppercase codes', () => {
|
||||
expect(isValidPrivilegeKeyCode('ADMIN.SETTINGS.USER.PRIVILEGES')).toBe(true);
|
||||
expect(isValidPrivilegeKeyCode('ADMIN.SALES.ACTIVITIES.INVOICE')).toBe(true);
|
||||
expect(isValidPrivilegeKeyCode('MOBILE.SALES.PLAN')).toBe(true);
|
||||
expect(assertPrivilegeKeyCode('MOBILE.SALES.PLAN')).toBe('MOBILE.SALES.PLAN');
|
||||
});
|
||||
|
||||
it('rejects invalid codes', () => {
|
||||
expect(isValidPrivilegeKeyCode('PRIVILEGES')).toBe(false);
|
||||
expect(isValidPrivilegeKeyCode('sales.invoice')).toBe(false);
|
||||
expect(isValidPrivilegeKeyCode('SALES.')).toBe(false);
|
||||
expect(isValidPrivilegeKeyCode('.SALES')).toBe(false);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
assertPrivilegeKeyCode,
|
||||
isValidPrivilegeKeyCode,
|
||||
parsePrivilegeKeyCode,
|
||||
} from './privilege-key-code';
|
||||
|
||||
describe('privilege-key-code', () => {
|
||||
describe('isValidPrivilegeKeyCode', () => {
|
||||
it('accepts 3-part keys', () => {
|
||||
expect(isValidPrivilegeKeyCode('MOBILE.SALES.PLAN')).toBe(true);
|
||||
expect(isValidPrivilegeKeyCode('ADMIN.SALES.REPORT')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts 4-part keys', () => {
|
||||
expect(isValidPrivilegeKeyCode('MOBILE.SALES.PLAN.ATTENDANCE')).toBe(true);
|
||||
expect(isValidPrivilegeKeyCode('ADMIN.SALES.ACTIVITIES.PLAN')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects fewer than 3 segments', () => {
|
||||
expect(isValidPrivilegeKeyCode('PRIVILEGES')).toBe(false);
|
||||
expect(isValidPrivilegeKeyCode('SALES.PLAN')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects more than 4 segments', () => {
|
||||
expect(isValidPrivilegeKeyCode('A.B.C.D.E')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid characters', () => {
|
||||
expect(isValidPrivilegeKeyCode('mobile.sales.plan')).toBe(false);
|
||||
expect(isValidPrivilegeKeyCode('MOBILE..PLAN')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertPrivilegeKeyCode', () => {
|
||||
it('returns the code when valid', () => {
|
||||
expect(assertPrivilegeKeyCode('MOBILE.SALES.PLAN')).toBe('MOBILE.SALES.PLAN');
|
||||
});
|
||||
|
||||
it('throws when invalid', () => {
|
||||
expect(() => assertPrivilegeKeyCode('PRIVILEGES')).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePrivilegeKeyCode', () => {
|
||||
it('parses a 3-part key with null submodule', () => {
|
||||
expect(parsePrivilegeKeyCode('MOBILE.SALES.PLAN')).toEqual({
|
||||
group: 'MOBILE',
|
||||
parent: 'SALES',
|
||||
module: 'PLAN',
|
||||
submodule: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a 4-part key', () => {
|
||||
expect(parsePrivilegeKeyCode('MOBILE.SALES.PLAN.ATTENDANCE')).toEqual({
|
||||
group: 'MOBILE',
|
||||
parent: 'SALES',
|
||||
module: 'PLAN',
|
||||
submodule: 'ATTENDANCE',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws for invalid codes', () => {
|
||||
expect(() => parsePrivilegeKeyCode('PRIVILEGES')).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,13 @@
|
||||
/** Dotted uppercase module levels: MODULE / MODULE.RESOURCE / MODULE.RESOURCE.SUB */
|
||||
/** Dotted uppercase: Group.Parent.Module or Group.Parent.Module.Submodule */
|
||||
export const PRIVILEGE_KEY_CODE_PATTERN =
|
||||
/^[A-Z][A-Z0-9_]*(\.[A-Z][A-Z0-9_]*)*$/;
|
||||
/^[A-Z][A-Z0-9_]*\.[A-Z][A-Z0-9_]*\.[A-Z][A-Z0-9_]*(?:\.[A-Z][A-Z0-9_]*)?$/;
|
||||
|
||||
export type ParsedPrivilegeKeyCode = {
|
||||
readonly group: string;
|
||||
readonly parent: string;
|
||||
readonly module: string;
|
||||
readonly submodule: string | null;
|
||||
};
|
||||
|
||||
export function isValidPrivilegeKeyCode(code: string): boolean {
|
||||
return typeof code === 'string' && PRIVILEGE_KEY_CODE_PATTERN.test(code);
|
||||
@@ -12,3 +19,14 @@ export function assertPrivilegeKeyCode(code: string): string {
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
export function parsePrivilegeKeyCode(code: string): ParsedPrivilegeKeyCode {
|
||||
assertPrivilegeKeyCode(code);
|
||||
const parts = code.split('.');
|
||||
if (parts.length === 3) {
|
||||
const [group, parent, module] = parts;
|
||||
return { group, parent, module, submodule: null };
|
||||
}
|
||||
const [group, parent, module, submodule] = parts;
|
||||
return { group, parent, module, submodule };
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export class PrivilegeKeysController {
|
||||
|
||||
@Get()
|
||||
@Pagination()
|
||||
@RequirePrivilege('PRIVILEGES', 'view')
|
||||
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'view')
|
||||
@ApiOperation({ summary: 'List privilege keys catalog' })
|
||||
@ApiOkResponse({
|
||||
schema: {
|
||||
|
||||
@@ -30,7 +30,7 @@ export class PrivilegesReadController {
|
||||
|
||||
@Get()
|
||||
@Pagination()
|
||||
@RequirePrivilege('PRIVILEGES', 'view')
|
||||
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'view')
|
||||
@ApiOperation({ summary: 'List privileges' })
|
||||
@ApiOkResponse({
|
||||
schema: {
|
||||
@@ -52,7 +52,7 @@ export class PrivilegesReadController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePrivilege('PRIVILEGES', 'view')
|
||||
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'view')
|
||||
@ApiOperation({ summary: 'Get privilege detail with matrix' })
|
||||
@ApiOkResponse({ type: PrivilegeDetailResponseDto })
|
||||
@ApiNotFoundResponse()
|
||||
|
||||
@@ -46,7 +46,7 @@ export class PrivilegesWriteController {
|
||||
constructor(private readonly privilegesService: PrivilegesService) {}
|
||||
|
||||
@Post('import')
|
||||
@RequirePrivilege('PRIVILEGES', 'import')
|
||||
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'import')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: 1_048_576 },
|
||||
@@ -90,7 +90,7 @@ export class PrivilegesWriteController {
|
||||
|
||||
@Post('bulk-delete')
|
||||
@HttpCode(200)
|
||||
@RequirePrivilege('PRIVILEGES', 'delete')
|
||||
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'delete')
|
||||
@ApiOperation({ summary: 'Bulk delete privileges' })
|
||||
@ApiOkResponse({
|
||||
schema: { properties: { deleted: { type: 'number' } } },
|
||||
@@ -103,7 +103,7 @@ export class PrivilegesWriteController {
|
||||
|
||||
@Post('bulk-status')
|
||||
@HttpCode(200)
|
||||
@RequirePrivilege('PRIVILEGES', 'update')
|
||||
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'update')
|
||||
@ApiOperation({ summary: 'Bulk update privilege status' })
|
||||
@ApiOkResponse({
|
||||
schema: { properties: { updated: { type: 'number' } } },
|
||||
@@ -118,7 +118,7 @@ export class PrivilegesWriteController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePrivilege('PRIVILEGES', 'create')
|
||||
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'create')
|
||||
@ApiOperation({ summary: 'Create privilege' })
|
||||
@ApiCreatedResponse({ type: PrivilegeDetailResponseDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@@ -137,7 +137,7 @@ export class PrivilegesWriteController {
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@RequirePrivilege('PRIVILEGES', 'update')
|
||||
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'update')
|
||||
@ApiOperation({ summary: 'Update privilege status' })
|
||||
@ApiOkResponse({ type: PrivilegeDto })
|
||||
@ApiNotFoundResponse()
|
||||
@@ -152,7 +152,7 @@ export class PrivilegesWriteController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePrivilege('PRIVILEGES', 'update')
|
||||
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'update')
|
||||
@ApiOperation({ summary: 'Update privilege (not status)' })
|
||||
@ApiOkResponse({ type: PrivilegeDetailResponseDto })
|
||||
@ApiNotFoundResponse()
|
||||
@@ -173,7 +173,7 @@ export class PrivilegesWriteController {
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@RequirePrivilege('PRIVILEGES', 'delete')
|
||||
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'delete')
|
||||
@ApiOperation({ summary: 'Delete privilege' })
|
||||
@ApiNoContentResponse()
|
||||
@ApiNotFoundResponse()
|
||||
|
||||
@@ -389,6 +389,39 @@ export class PrivilegesRepository {
|
||||
return row?.value === true;
|
||||
}
|
||||
|
||||
async checkAnyPermission(
|
||||
userId: string,
|
||||
keyCodes: readonly string[],
|
||||
action: PrivilegeAction,
|
||||
): Promise<boolean> {
|
||||
if (keyCodes.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const [row] = await this.db
|
||||
.select({ value: privilegeDetails.value })
|
||||
.from(users)
|
||||
.innerJoin(privileges, eq(users.privilegeId, privileges.id))
|
||||
.innerJoin(
|
||||
privilegeDetails,
|
||||
eq(privilegeDetails.privilegeId, privileges.id),
|
||||
)
|
||||
.innerJoin(
|
||||
privilegeKeys,
|
||||
eq(privilegeDetails.privilegeKeyId, privilegeKeys.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(users.id, userId),
|
||||
eq(privileges.status, 'active'),
|
||||
inArray(privilegeKeys.code, [...keyCodes]),
|
||||
eq(privilegeDetails.action, action),
|
||||
eq(privilegeDetails.value, true),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return row?.value === true;
|
||||
}
|
||||
|
||||
async getPermissionsMap(
|
||||
privilegeId: string,
|
||||
): Promise<Record<string, Record<PrivilegeAction, boolean>>> {
|
||||
|
||||
@@ -23,6 +23,7 @@ describe('PrivilegesService', () => {
|
||||
| 'listKeys'
|
||||
| 'findKeyById'
|
||||
| 'checkPermission'
|
||||
| 'checkAnyPermission'
|
||||
| 'getPermissionsMap'
|
||||
>
|
||||
>;
|
||||
@@ -56,6 +57,7 @@ describe('PrivilegesService', () => {
|
||||
listKeys: jest.fn(),
|
||||
findKeyById: jest.fn(),
|
||||
checkPermission: jest.fn(),
|
||||
checkAnyPermission: jest.fn(),
|
||||
getPermissionsMap: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -140,7 +142,44 @@ describe('PrivilegesService', () => {
|
||||
it('checkPermission delegates', async () => {
|
||||
repository.checkPermission.mockResolvedValue(true);
|
||||
await expect(
|
||||
service.checkPermission('user-1', 'PRIVILEGES', 'view'),
|
||||
service.checkPermission(
|
||||
'user-1',
|
||||
'ADMIN.SETTINGS.USER.PRIVILEGES',
|
||||
'view',
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('checkAnyPermission delegates for multiple keys', async () => {
|
||||
repository.checkAnyPermission.mockResolvedValue(true);
|
||||
await expect(
|
||||
service.checkAnyPermission(
|
||||
'user-1',
|
||||
['ADMIN.SALES.ACTIVITIES.PLAN', 'MOBILE.SALES.PLAN'],
|
||||
'view',
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
expect(repository.checkAnyPermission).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
['ADMIN.SALES.ACTIVITIES.PLAN', 'MOBILE.SALES.PLAN'],
|
||||
'view',
|
||||
);
|
||||
});
|
||||
|
||||
it('checkAnyPermission uses checkPermission for a single key', async () => {
|
||||
repository.checkPermission.mockResolvedValue(true);
|
||||
await expect(
|
||||
service.checkAnyPermission(
|
||||
'user-1',
|
||||
['MOBILE.SALES.PLAN'],
|
||||
'view',
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
expect(repository.checkPermission).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
'MOBILE.SALES.PLAN',
|
||||
'view',
|
||||
);
|
||||
expect(repository.checkAnyPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -262,6 +262,24 @@ export class PrivilegesService {
|
||||
return this.privilegesRepository.checkPermission(userId, keyCode, action);
|
||||
}
|
||||
|
||||
async checkAnyPermission(
|
||||
userId: string,
|
||||
keyCodes: readonly string[],
|
||||
action: PrivilegeAction,
|
||||
): Promise<boolean> {
|
||||
if (keyCodes.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (keyCodes.length === 1) {
|
||||
return this.checkPermission(userId, keyCodes[0], action);
|
||||
}
|
||||
return this.privilegesRepository.checkAnyPermission(
|
||||
userId,
|
||||
keyCodes,
|
||||
action,
|
||||
);
|
||||
}
|
||||
|
||||
async getPermissionsMap(
|
||||
privilegeId: string,
|
||||
): Promise<Record<string, Record<PrivilegeAction, boolean>>> {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../../common/http/response';
|
||||
import {
|
||||
REPORT_BOOKMARK_TYPE,
|
||||
type ReportBookmarkType,
|
||||
} from '../../shared/constants/bookmark-type';
|
||||
|
||||
export class ReportBookmarkDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
groupName!: string;
|
||||
|
||||
@ApiProperty()
|
||||
uniqueName!: string;
|
||||
|
||||
@ApiProperty()
|
||||
label!: string;
|
||||
|
||||
@ApiProperty({ enum: Object.values(REPORT_BOOKMARK_TYPE) })
|
||||
type!: ReportBookmarkType;
|
||||
|
||||
@ApiProperty()
|
||||
applied!: boolean;
|
||||
|
||||
@ApiProperty({ type: 'object', additionalProperties: true })
|
||||
configuration!: unknown;
|
||||
|
||||
@ApiProperty()
|
||||
status!: string;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt!: number;
|
||||
|
||||
@ApiProperty()
|
||||
updatedAt!: number;
|
||||
}
|
||||
|
||||
export class ListReportBookmarksQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
groupName?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
uniqueName?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: Object.values(REPORT_BOOKMARK_TYPE) })
|
||||
@IsOptional()
|
||||
@IsIn(Object.values(REPORT_BOOKMARK_TYPE))
|
||||
type?: ReportBookmarkType;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export class CreateReportBookmarkDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
groupName!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
uniqueName!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
label!: string;
|
||||
|
||||
@ApiProperty({ enum: Object.values(REPORT_BOOKMARK_TYPE) })
|
||||
@IsIn(Object.values(REPORT_BOOKMARK_TYPE))
|
||||
type!: ReportBookmarkType;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
applied?: boolean;
|
||||
|
||||
@ApiProperty({ type: 'object', additionalProperties: true })
|
||||
@IsObject()
|
||||
configuration!: unknown;
|
||||
}
|
||||
|
||||
export class AppliedBookmarkQueryDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
groupName!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
uniqueName!: string;
|
||||
|
||||
@ApiProperty({ enum: Object.values(REPORT_BOOKMARK_TYPE) })
|
||||
@IsIn(Object.values(REPORT_BOOKMARK_TYPE))
|
||||
type!: ReportBookmarkType;
|
||||
}
|
||||
|
||||
export class LabelHistoryQueryDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
label?: string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ReportBookmarksReadController } from './report-bookmarks-read.controller';
|
||||
import { ReportBookmarksWriteController } from './report-bookmarks-write.controller';
|
||||
import { ReportBookmarksRepository } from './report-bookmarks.repository';
|
||||
import { ReportBookmarksService } from './report-bookmarks.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ReportBookmarksReadController, ReportBookmarksWriteController],
|
||||
providers: [ReportBookmarksRepository, ReportBookmarksService],
|
||||
exports: [ReportBookmarksRepository, ReportBookmarksService],
|
||||
})
|
||||
export class ReportBookmarkModule {}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Put,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiForbiddenResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||
import type { AuthUser } from '../../../common/auth/auth-user';
|
||||
import {
|
||||
Pagination,
|
||||
type PaginationResponse,
|
||||
PaginationMetaDto,
|
||||
} from '../../../common/http/response';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import {
|
||||
AppliedBookmarkQueryDto,
|
||||
LabelHistoryQueryDto,
|
||||
ListReportBookmarksQueryDto,
|
||||
ReportBookmarkDto,
|
||||
} from './dto/report-bookmark.dto';
|
||||
import { ReportBookmarksService } from './report-bookmarks.service';
|
||||
|
||||
@ApiTags('report-bookmarks')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('report-bookmarks')
|
||||
export class ReportBookmarksReadController {
|
||||
constructor(private readonly bookmarksService: ReportBookmarksService) {}
|
||||
|
||||
@Get()
|
||||
@Pagination()
|
||||
@ApiOperation({ summary: 'List report bookmarks for current user' })
|
||||
@ApiOkResponse({
|
||||
schema: {
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/ReportBookmarkDto' },
|
||||
},
|
||||
meta: { $ref: '#/components/schemas/PaginationMetaDto' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
list(
|
||||
@Query() query: ListReportBookmarksQueryDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<PaginationResponse<ReportBookmarkDto>> {
|
||||
return this.bookmarksService.list(user.id, query);
|
||||
}
|
||||
|
||||
@Get('label-history')
|
||||
@ApiOperation({ summary: 'Distinct bookmark labels' })
|
||||
@ApiOkResponse({ type: [String] })
|
||||
labelHistory(
|
||||
@Query() query: LabelHistoryQueryDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.bookmarksService.labelHistory(user.id, query.label);
|
||||
}
|
||||
|
||||
@Get('applied')
|
||||
@ApiOperation({ summary: 'Get applied bookmark for report and type' })
|
||||
@ApiOkResponse({ type: ReportBookmarkDto })
|
||||
findApplied(
|
||||
@Query() query: AppliedBookmarkQueryDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.bookmarksService.findApplied(
|
||||
user.id,
|
||||
query.groupName,
|
||||
query.uniqueName,
|
||||
query.type,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void PaginationMetaDto;
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Put,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiCreatedResponse,
|
||||
ApiForbiddenResponse,
|
||||
ApiNoContentResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||
import type { AuthUser } from '../../../common/auth/auth-user';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import {
|
||||
CreateReportBookmarkDto,
|
||||
ReportBookmarkDto,
|
||||
} from './dto/report-bookmark.dto';
|
||||
import { ReportBookmarksService } from './report-bookmarks.service';
|
||||
|
||||
@ApiTags('report-bookmarks')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('report-bookmarks')
|
||||
export class ReportBookmarksWriteController {
|
||||
constructor(private readonly bookmarksService: ReportBookmarksService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create report bookmark' })
|
||||
@ApiCreatedResponse({ type: ReportBookmarkDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
create(@Body() dto: CreateReportBookmarkDto, @CurrentUser() user: AuthUser) {
|
||||
return this.bookmarksService.create(user.id, dto);
|
||||
}
|
||||
|
||||
@Put('applied/:id')
|
||||
@ApiOperation({ summary: 'Apply report bookmark' })
|
||||
@ApiOkResponse({ type: ReportBookmarkDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
apply(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthUser) {
|
||||
return this.bookmarksService.apply(user.id, id);
|
||||
}
|
||||
|
||||
@Put('unapplied/:id')
|
||||
@ApiOperation({ summary: 'Unapply report bookmark' })
|
||||
@ApiOkResponse({ type: ReportBookmarkDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
unapply(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.bookmarksService.unapply(user.id, id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete report bookmark' })
|
||||
@ApiNoContentResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
async delete(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<void> {
|
||||
await this.bookmarksService.delete(user.id, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { and, desc, eq, ilike } from 'drizzle-orm';
|
||||
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 { reportBookmarks } from '../../../database/report-bookmarks-table';
|
||||
import type { ReportBookmarkType } from '../shared/constants/bookmark-type';
|
||||
|
||||
export type ReportBookmark = {
|
||||
id: string;
|
||||
groupName: string;
|
||||
uniqueName: string;
|
||||
label: string;
|
||||
type: ReportBookmarkType;
|
||||
applied: boolean;
|
||||
configuration: unknown;
|
||||
status: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
createdBy: string;
|
||||
updatedBy: string;
|
||||
};
|
||||
|
||||
export type ListReportBookmarksFilters = {
|
||||
groupName?: string;
|
||||
uniqueName?: string;
|
||||
type?: ReportBookmarkType;
|
||||
label?: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ReportBookmarksRepository {
|
||||
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||
|
||||
async list(
|
||||
ownerId: string,
|
||||
filters: ListReportBookmarksFilters,
|
||||
): Promise<{ data: ReportBookmark[]; total: number }> {
|
||||
const conditions = [eq(reportBookmarks.createdBy, ownerId)];
|
||||
if (filters.groupName) {
|
||||
conditions.push(eq(reportBookmarks.groupName, filters.groupName));
|
||||
}
|
||||
if (filters.uniqueName) {
|
||||
conditions.push(eq(reportBookmarks.uniqueName, filters.uniqueName));
|
||||
}
|
||||
if (filters.type) {
|
||||
conditions.push(eq(reportBookmarks.type, filters.type));
|
||||
}
|
||||
if (filters.label) {
|
||||
conditions.push(ilike(reportBookmarks.label, `%${filters.label}%`));
|
||||
}
|
||||
const where = and(...conditions);
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(reportBookmarks)
|
||||
.where(where)
|
||||
.orderBy(desc(reportBookmarks.updatedAt))
|
||||
.limit(filters.limit)
|
||||
.offset(filters.offset);
|
||||
const totalRows = await this.db
|
||||
.select({ id: reportBookmarks.id })
|
||||
.from(reportBookmarks)
|
||||
.where(where);
|
||||
return {
|
||||
data: rows.map((row) => this.toDomain(row)),
|
||||
total: totalRows.length,
|
||||
};
|
||||
}
|
||||
|
||||
async findById(ownerId: string, id: string): Promise<ReportBookmark | null> {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(reportBookmarks)
|
||||
.where(
|
||||
and(eq(reportBookmarks.id, id), eq(reportBookmarks.createdBy, ownerId)),
|
||||
);
|
||||
const row = rows[0];
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
|
||||
async findApplied(
|
||||
ownerId: string,
|
||||
groupName: string,
|
||||
uniqueName: string,
|
||||
type: ReportBookmarkType,
|
||||
): Promise<ReportBookmark | null> {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(reportBookmarks)
|
||||
.where(
|
||||
and(
|
||||
eq(reportBookmarks.createdBy, ownerId),
|
||||
eq(reportBookmarks.groupName, groupName),
|
||||
eq(reportBookmarks.uniqueName, uniqueName),
|
||||
eq(reportBookmarks.type, type),
|
||||
eq(reportBookmarks.applied, true),
|
||||
),
|
||||
);
|
||||
const row = rows[0];
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
|
||||
async labelHistory(ownerId: string, prefix?: string): Promise<string[]> {
|
||||
const rows = await this.db
|
||||
.select({ label: reportBookmarks.label })
|
||||
.from(reportBookmarks)
|
||||
.where(eq(reportBookmarks.createdBy, ownerId))
|
||||
.orderBy(desc(reportBookmarks.updatedAt));
|
||||
const labels = rows.map((r) => r.label);
|
||||
if (prefix) {
|
||||
return [...new Set(labels.filter((l) => l.includes(prefix)))];
|
||||
}
|
||||
return [...new Set(labels)];
|
||||
}
|
||||
|
||||
async create(
|
||||
ownerId: string,
|
||||
input: {
|
||||
groupName: string;
|
||||
uniqueName: string;
|
||||
label: string;
|
||||
type: ReportBookmarkType;
|
||||
applied: boolean;
|
||||
configuration: unknown;
|
||||
},
|
||||
): Promise<ReportBookmark> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
if (input.applied) {
|
||||
await this.unapplySiblings(
|
||||
ownerId,
|
||||
input.groupName,
|
||||
input.uniqueName,
|
||||
input.type,
|
||||
now.value,
|
||||
ownerId,
|
||||
);
|
||||
}
|
||||
const rows = await this.db
|
||||
.insert(reportBookmarks)
|
||||
.values({
|
||||
groupName: input.groupName,
|
||||
uniqueName: input.uniqueName,
|
||||
label: input.label,
|
||||
type: input.type,
|
||||
applied: input.applied,
|
||||
configuration: input.configuration,
|
||||
status: Status.DEFAULT,
|
||||
createdAt: now.value,
|
||||
updatedAt: now.value,
|
||||
createdBy: ownerId,
|
||||
updatedBy: ownerId,
|
||||
})
|
||||
.returning();
|
||||
return this.toDomain(rows[0]!);
|
||||
}
|
||||
|
||||
async apply(ownerId: string, id: string): Promise<ReportBookmark> {
|
||||
const bookmark = await this.findById(ownerId, id);
|
||||
if (!bookmark) {
|
||||
throw new NotFoundException('Bookmark not found');
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
await this.unapplySiblings(
|
||||
ownerId,
|
||||
bookmark.groupName,
|
||||
bookmark.uniqueName,
|
||||
bookmark.type,
|
||||
now.value,
|
||||
ownerId,
|
||||
);
|
||||
const rows = await this.db
|
||||
.update(reportBookmarks)
|
||||
.set({ applied: true, updatedAt: now.value, updatedBy: ownerId })
|
||||
.where(
|
||||
and(eq(reportBookmarks.id, id), eq(reportBookmarks.createdBy, ownerId)),
|
||||
)
|
||||
.returning();
|
||||
return this.toDomain(rows[0]!);
|
||||
}
|
||||
|
||||
async unapply(ownerId: string, id: string): Promise<ReportBookmark> {
|
||||
const bookmark = await this.findById(ownerId, id);
|
||||
if (!bookmark) {
|
||||
throw new NotFoundException('Bookmark not found');
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const rows = await this.db
|
||||
.update(reportBookmarks)
|
||||
.set({ applied: false, updatedAt: now.value, updatedBy: ownerId })
|
||||
.where(
|
||||
and(eq(reportBookmarks.id, id), eq(reportBookmarks.createdBy, ownerId)),
|
||||
)
|
||||
.returning();
|
||||
return this.toDomain(rows[0]!);
|
||||
}
|
||||
|
||||
async delete(ownerId: string, id: string): Promise<void> {
|
||||
const bookmark = await this.findById(ownerId, id);
|
||||
if (!bookmark) {
|
||||
throw new NotFoundException('Bookmark not found');
|
||||
}
|
||||
await this.db
|
||||
.delete(reportBookmarks)
|
||||
.where(
|
||||
and(eq(reportBookmarks.id, id), eq(reportBookmarks.createdBy, ownerId)),
|
||||
);
|
||||
}
|
||||
|
||||
private async unapplySiblings(
|
||||
ownerId: string,
|
||||
groupName: string,
|
||||
uniqueName: string,
|
||||
type: ReportBookmarkType,
|
||||
updatedAt: number,
|
||||
updatedBy: string,
|
||||
) {
|
||||
await this.db
|
||||
.update(reportBookmarks)
|
||||
.set({ applied: false, updatedAt, updatedBy })
|
||||
.where(
|
||||
and(
|
||||
eq(reportBookmarks.createdBy, ownerId),
|
||||
eq(reportBookmarks.groupName, groupName),
|
||||
eq(reportBookmarks.uniqueName, uniqueName),
|
||||
eq(reportBookmarks.type, type),
|
||||
eq(reportBookmarks.applied, true),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private toDomain(row: typeof reportBookmarks.$inferSelect): ReportBookmark {
|
||||
return {
|
||||
id: row.id,
|
||||
groupName: row.groupName,
|
||||
uniqueName: row.uniqueName,
|
||||
label: row.label,
|
||||
type: row.type as ReportBookmarkType,
|
||||
applied: row.applied,
|
||||
configuration: row.configuration,
|
||||
status: row.status,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy,
|
||||
};
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user