11 Commits
Author SHA1 Message Date
shancheas 82e4a0cbf0 Enhance check-in verification to ensure integer distance values
- Added a new function, `roundedDistanceMeters`, to round the calculated distance for NFC and GPS methods.
- Updated the `verifyTargetCheckIn` function to use the new rounding logic, ensuring distance values are integers.
- Added unit tests to verify that distance values returned for NFC check-ins are whole numbers, improving data consistency.
2026-09-01 23:48:12 +07:00
shancheas ec5f012d6f Add SKIP_GPS_VALIDATION feature for attendance and visit services
- Introduced SKIP_GPS_VALIDATION environment variable to control GPS validation during check-ins and check-outs.
- Updated loadEnv function to parse SKIP_GPS_VALIDATION and enforce its rules based on NODE_ENV.
- Enhanced attendance and visit services to utilize the new configuration, allowing GPS validation to be skipped in development environments.
- Added unit tests to verify the behavior of SKIP_GPS_VALIDATION in various scenarios, ensuring proper handling in both production and development contexts.
- Updated check-in verification logic to respect the SKIP_GPS_VALIDATION option, improving flexibility in location checks.
2026-09-01 22:01:25 +07:00
shancheas 4b48dbdf3c Add timeline tracking features with database schema updates
- Introduced new tables `timeline_footprints` and `timeline_activities` to manage employee location data and activity records.
- Updated `company_settings` to include `gps_interval_seconds` and `checkout_warning_radius_meters` for enhanced tracking configuration.
- Implemented foreign key constraints to ensure data integrity between new tables and existing `employees`, `customers`, and `visits` tables.
- Created services and controllers for handling timeline activities and footprints, including ingestion and retrieval of data.
- Enhanced DTOs and validation logic to support new fields and ensure correct data formats in API requests.
- Added unit and integration tests to validate the new functionalities and ensure proper handling of timeline records.
- Created migration scripts to apply the necessary database schema changes for the new features.
2026-09-01 20:36:47 +07:00
shancheas 9428a983f5 Refactor PlansRepository to optimize data loading and enhance error handling
- Replaced the previous hydration logic with a new method, `loadChildrenForRows`, to efficiently load related data for plans in a single query.
- Implemented a `groupByPlanId` utility to organize related entities by plan ID, improving data retrieval performance.
- Updated error handling in the `getPlan` method to throw a `NotFoundException` if a plan is not found.
- Modified end-to-end tests to validate the new structure of the response, ensuring that related destinations are correctly included in the API output.
2026-09-01 19:25:19 +07:00
shancheas 6b3ddfcff9 Add date and createdBy filters to sales payments functionality
- Introduced new filters `date` and `createdBy` in the `ListSalesPaymentsFilters` and `ListSalesPaymentsQuery` types to enhance querying capabilities.
- Updated the `SalesPaymentsRepository` to handle filtering based on the new fields.
- Enhanced the `SalesPaymentsService` to process the new filters and ensure proper date handling.
- Added unit tests to validate the integration of the new filters in the service layer.
- Updated DTOs to include validation for the new fields, ensuring correct data formats in API requests.
2026-09-01 15:29:07 +07:00
shancheas 51db4f4a4d Refactor privilege management to support hierarchical privilege keys
- Updated privilege key structure to use a 3- or 4-part dotted hierarchy (e.g., `GROUP.PARENT.MODULE`).
- Modified the `RequirePrivilege` decorator to accept multiple keys, allowing for OR logic in privilege checks.
- Enhanced `PrivilegesGuard` to validate against multiple privilege keys, improving access control logic.
- Created migration scripts to update existing privilege keys in the database to the new format.
- Updated related services, controllers, and tests to accommodate the new privilege key structure and validation logic.
2026-09-01 13:14:58 +07:00
shancheas 365a37b8d2 Add attendance and visit management features with database schema updates
- Introduced new `attendances` and `visits` tables to manage employee attendance and customer visits, including relevant fields for check-in and check-out details.
- Updated `company_settings` to include a `check_in_radius_meters` column for attendance validation.
- Implemented foreign key constraints to ensure data integrity between `attendances`, `visits`, `employees`, `branches`, and other related entities.
- Created new services and controllers for handling attendance and visit operations, including check-in, check-out, and bulk actions.
- Enhanced DTOs for attendance and visit data transfer, including validation for input data.
- Added unit and integration tests to validate the new functionalities and ensure proper handling of attendance and visit records.
- Created migration scripts to apply the necessary database schema changes for the new features.
2026-09-01 12:27:51 +07:00
shancheas 0e73d14381 Add address fields to sales invoices and implement related validations
- Introduced new columns `address`, `latitude`, and `longitude` in the `sales_invoices` table to store location details.
- Updated the `SalesInvoicesService` to handle the new fields, including validation for address format and geographical coordinates.
- Enhanced the `SalesInvoiceDto` and related data transfer objects to include the new fields for API requests and responses.
- Added unit and e2e tests to ensure proper handling of the new fields and validate their integration within the sales invoice workflow.
- Created a new migration script to apply the database schema changes for the sales invoices.
2026-09-01 09:50:19 +07:00
shancheas 23028abd48 Implement foreign key violation handling in EmployeesRepository delete methods
- Enhanced the `delete` and `bulkDelete` methods in `EmployeesRepository` to handle foreign key violations by throwing a `ConflictException` with a descriptive message.
- Added unit tests to verify that foreign key violations are correctly mapped to `ConflictException` and that unknown errors are rethrown as expected.
- Refactored error handling in the `delete` methods to improve clarity and maintainability.
2026-09-01 08:57:31 +07:00
shancheas 2955b974d2 Add reporting features with new report engine and bookmark management
- Introduced a comprehensive report engine for generating and managing reports, including sales and logistics reports.
- Added new API endpoints for retrieving report configurations, data, and metadata, ensuring secure access with privilege checks.
- Implemented a report bookmarks system to allow users to save and manage report filters and configurations.
- Created database migrations for the `report_bookmarks` table and updated the schema to support new report functionalities.
- Developed services and controllers for handling report queries and bookmarks, including CRUD operations for bookmarks.
- Enhanced API documentation to reflect the new reporting features and endpoints.
- Added unit and integration tests to validate the new functionalities and ensure data integrity across report operations.
2026-09-01 08:45:52 +07:00
shancheas 5579cf6566 Add payable filter to sales invoices and enhance invoice status handling
- Introduced a new `payable` filter in the `ListSalesInvoicesFilters` and `ListSalesInvoicesQuery` types to allow querying of invoices based on their payable status.
- Updated the `SalesInvoicesRepository` to incorporate logic for filtering invoices that are payable, checking both status and balance.
- Enhanced the `SalesInvoicesService` to support the new `payable` filter in query handling.
- Modified the `SalesInvoiceDto` to include the `payable` property for better API response representation.
- Added unit tests to validate the new filter functionality and ensure proper handling of invoice statuses during updates.
- Updated e2e tests to cover scenarios involving the new payable filter and status transitions for invoices.
2026-08-31 15:15:06 +07:00
166 changed files with 8874 additions and 236 deletions
+3 -3
View File
@@ -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).
+4
View File
@@ -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
View File
@@ -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).
+52
View File
@@ -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.
+104
View File
@@ -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.
+21
View File
@@ -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);
+42
View File
@@ -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);
+35
View File
@@ -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
}
]
}
+2
View File
@@ -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);
+41 -13
View File
@@ -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(
+2 -2
View File
@@ -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) {
+1
View File
@@ -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;
}
+30
View File
@@ -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');
});
});
+29
View File
@@ -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,
};
}
+37
View File
@@ -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;
+19
View File
@@ -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'),
};
+6 -1
View File
@@ -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),
});
+33
View File
@@ -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;
+4
View File
@@ -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),
},
+23
View File
@@ -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';
+56
View File
@@ -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;
+24
View File
@@ -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;
+47
View File
@@ -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;
+12 -2
View File
@@ -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: {},
});
});
+6
View File
@@ -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,
};
}
+2
View File
@@ -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({
+18 -1
View File
@@ -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 = {
+5 -5
View File
@@ -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) {
+26 -1
View File
@@ -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 {}
+56 -17
View File
@@ -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(
+95 -6
View File
@@ -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);
+49 -17
View File
@@ -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', () => {
+19 -11
View File
@@ -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 {
+21
View File
@@ -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;
};
+230
View File
@@ -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;
}
+72
View File
@@ -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;
}
}
+302
View File
@@ -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;
+2 -2
View File
@@ -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);
});
});
});
+20 -2
View File
@@ -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