Compare commits
7
Commits
0e73d14381
...
82e4a0cbf0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82e4a0cbf0 | ||
|
|
ec5f012d6f | ||
|
|
4b48dbdf3c | ||
|
|
9428a983f5 | ||
|
|
6b3ddfcff9 | ||
|
|
51db4f4a4d | ||
|
|
365a37b8d2 |
@@ -11,7 +11,7 @@ alwaysApply: false
|
|||||||
Every **non-public** controller handler on a primary (CRUD) resource MUST use:
|
Every **non-public** controller handler on a primary (CRUD) resource MUST use:
|
||||||
|
|
||||||
```typescript
|
```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:
|
Map HTTP verbs to actions:
|
||||||
@@ -24,13 +24,13 @@ Map HTTP verbs to actions:
|
|||||||
| `DELETE /:id`, bulk-delete | `delete` |
|
| `DELETE /:id`, bulk-delete | `delete` |
|
||||||
| `POST /import` | `import` |
|
| `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.
|
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
|
## 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).
|
Do not set `is_superadmin` via register/login. Default is `false`; promote via SQL/ops (`UPDATE users SET is_superadmin = true`). The flag is loaded from the database on each JWT validation (not from JWT claims).
|
||||||
|
|
||||||
|
|||||||
@@ -23,3 +23,7 @@ BCRYPT_SALT_ROUNDS=10
|
|||||||
# OpenAPI UI at /docs (default: on unless NODE_ENV=production)
|
# OpenAPI UI at /docs (default: on unless NODE_ENV=production)
|
||||||
# SWAGGER_ENABLED=true
|
# SWAGGER_ENABLED=true
|
||||||
# SWAGGER_ENABLED=false
|
# 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
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
ALTER TABLE "company_settings" ADD COLUMN "check_in_radius_meters" integer DEFAULT 100 NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "attendances" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"employee_id" uuid NOT NULL,
|
||||||
|
"branch_id" uuid NOT NULL,
|
||||||
|
"date" bigint NOT NULL,
|
||||||
|
"check_in_at" bigint NOT NULL,
|
||||||
|
"check_in_method" text NOT NULL,
|
||||||
|
"check_in_latitude" double precision NOT NULL,
|
||||||
|
"check_in_longitude" double precision NOT NULL,
|
||||||
|
"check_in_photo_url" text,
|
||||||
|
"check_in_distance_meters" integer,
|
||||||
|
"check_out_at" bigint,
|
||||||
|
"check_out_method" text,
|
||||||
|
"check_out_latitude" double precision,
|
||||||
|
"check_out_longitude" double precision,
|
||||||
|
"check_out_photo_url" text,
|
||||||
|
"check_out_distance_meters" integer,
|
||||||
|
"status" text DEFAULT 'draft' NOT NULL,
|
||||||
|
"created_at" bigint NOT NULL,
|
||||||
|
"updated_at" bigint NOT NULL,
|
||||||
|
"created_by" uuid NOT NULL,
|
||||||
|
"updated_by" uuid NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "visits" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"employee_id" uuid NOT NULL,
|
||||||
|
"customer_id" uuid NOT NULL,
|
||||||
|
"attendance_id" uuid,
|
||||||
|
"plan_id" uuid,
|
||||||
|
"plan_destination_id" uuid,
|
||||||
|
"date" bigint NOT NULL,
|
||||||
|
"check_in_at" bigint NOT NULL,
|
||||||
|
"check_in_method" text NOT NULL,
|
||||||
|
"check_in_latitude" double precision NOT NULL,
|
||||||
|
"check_in_longitude" double precision NOT NULL,
|
||||||
|
"check_in_photo_url" text,
|
||||||
|
"check_in_distance_meters" integer,
|
||||||
|
"check_out_at" bigint,
|
||||||
|
"check_out_method" text,
|
||||||
|
"check_out_latitude" double precision,
|
||||||
|
"check_out_longitude" double precision,
|
||||||
|
"check_out_photo_url" text,
|
||||||
|
"check_out_distance_meters" integer,
|
||||||
|
"status" text DEFAULT 'draft' NOT NULL,
|
||||||
|
"created_at" bigint NOT NULL,
|
||||||
|
"updated_at" bigint NOT NULL,
|
||||||
|
"created_by" uuid NOT NULL,
|
||||||
|
"updated_by" uuid NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "attendances" ADD CONSTRAINT "attendances_employee_id_employees_id_fk" FOREIGN KEY ("employee_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "attendances" ADD CONSTRAINT "attendances_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches"("id") ON DELETE restrict ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "attendances" ADD CONSTRAINT "attendances_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "attendances" ADD CONSTRAINT "attendances_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "visits" ADD CONSTRAINT "visits_employee_id_employees_id_fk" FOREIGN KEY ("employee_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "visits" ADD CONSTRAINT "visits_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE restrict ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "visits" ADD CONSTRAINT "visits_attendance_id_attendances_id_fk" FOREIGN KEY ("attendance_id") REFERENCES "public"."attendances"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "visits" ADD CONSTRAINT "visits_plan_id_plans_id_fk" FOREIGN KEY ("plan_id") REFERENCES "public"."plans"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "visits" ADD CONSTRAINT "visits_plan_destination_id_plan_destinations_id_fk" FOREIGN KEY ("plan_destination_id") REFERENCES "public"."plan_destinations"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "visits" ADD CONSTRAINT "visits_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "visits" ADD CONSTRAINT "visits_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "attendances_employee_date_live_unique" ON "attendances" USING btree ("employee_id","date") WHERE "attendances"."status" <> 'archived';
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "attendances_employee_open_unique" ON "attendances" USING btree ("employee_id") WHERE "attendances"."check_out_at" IS NULL AND "attendances"."status" <> 'archived';
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "attendances_employee_id_idx" ON "attendances" USING btree ("employee_id");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "attendances_branch_id_idx" ON "attendances" USING btree ("branch_id");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "visits_employee_open_unique" ON "visits" USING btree ("employee_id") WHERE "visits"."check_out_at" IS NULL AND "visits"."status" <> 'archived';
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "visits_employee_id_idx" ON "visits" USING btree ("employee_id");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "visits_customer_id_idx" ON "visits" USING btree ("customer_id");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "visits_attendance_id_idx" ON "visits" USING btree ("attendance_id");
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES
|
||||||
|
('FIELD.ATTENDANCE', 'Branch attendance', 18),
|
||||||
|
('FIELD.VISIT', 'Customer visits', 19);
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
-- Rename existing privilege_keys to Group.Parent.Module[.Submodule] hierarchy
|
||||||
|
UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.USER.PRIVILEGES', "label" = 'Privileges', "sort_order" = 101 WHERE "code" = 'PRIVILEGES';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.USER.USERS', "label" = 'Users', "sort_order" = 102 WHERE "code" = 'USERS';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.DATA.DIVISION', "label" = 'Divisions', "sort_order" = 103 WHERE "code" = 'CONFIGURATION.DIVISION';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.DATA.BRANCH', "label" = 'Branches', "sort_order" = 104 WHERE "code" = 'CONFIGURATION.BRANCH';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.DATA.CUSTOMER', "label" = 'Customers', "sort_order" = 105 WHERE "code" = 'CONFIGURATION.CUSTOMER';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.DATA.PRODUCT', "label" = 'Products', "sort_order" = 106 WHERE "code" = 'CONFIGURATION.PRODUCT';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.DATA.SETTING', "label" = 'Company settings', "sort_order" = 107 WHERE "code" = 'CONFIGURATION.SETTING';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.DATA.EMPLOYEE', "label" = 'Employees', "sort_order" = 111 WHERE "code" = 'CONFIGURATION.EMPLOYEE';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.DATA.CYCLE', "label" = 'Sales cycles', "sort_order" = 112 WHERE "code" = 'SALES.CYCLE';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.ACTIVITIES.REQUEST', "label" = 'Sales requests', "sort_order" = 113 WHERE "code" = 'SALES.REQUEST';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.ACTIVITIES.ORDER', "label" = 'Sales orders', "sort_order" = 114 WHERE "code" = 'SALES.ORDER';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.ACTIVITIES.INVOICE', "label" = 'Sales invoices', "sort_order" = 115 WHERE "code" = 'SALES.INVOICE';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.ACTIVITIES.PAYMENT', "label" = 'Sales payments', "sort_order" = 116 WHERE "code" = 'SALES.PAYMENT';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.ACTIVITIES.PLAN', "label" = 'Sales plans', "sort_order" = 117 WHERE "code" = 'SALES.PLAN';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.REPORT', "label" = 'Sales reports', "sort_order" = 118 WHERE "code" = 'SALES.REPORT';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "privilege_keys" SET "code" = 'ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP', "label" = 'Packing slips', "sort_order" = 121 WHERE "code" = 'SALES.PACKING_SLIP';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "privilege_keys" SET "code" = 'ADMIN.LOGISTICS.DATA.CYCLE', "label" = 'Logistics cycles', "sort_order" = 122 WHERE "code" = 'LOGISTICS.CYCLE';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "privilege_keys" SET "code" = 'ADMIN.LOGISTICS.ACTIVITIES.PLAN', "label" = 'Logistics plans', "sort_order" = 123 WHERE "code" = 'LOGISTICS.PLAN';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "privilege_keys" SET "code" = 'ADMIN.LOGISTICS.REPORT', "label" = 'Logistics reports', "sort_order" = 124 WHERE "code" = 'LOGISTICS.REPORT';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "privilege_keys" SET "code" = 'MOBILE.SALES.PLAN.ATTENDANCE', "label" = 'Branch attendance', "sort_order" = 201 WHERE "code" = 'FIELD.ATTENDANCE';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "privilege_keys" SET "code" = 'MOBILE.SALES.VISIT', "label" = 'Customer visits', "sort_order" = 202 WHERE "code" = 'FIELD.VISIT';
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES
|
||||||
|
('MOBILE.SALES.PLAN', 'Sales plans (mobile)', 203),
|
||||||
|
('MOBILE.SALES.REQUEST', 'Sales requests (mobile)', 204),
|
||||||
|
('MOBILE.SALES.ORDER', 'Sales orders (mobile)', 205),
|
||||||
|
('MOBILE.SALES.INVOICE', 'Sales invoices (mobile)', 206),
|
||||||
|
('MOBILE.SALES.PAYMENT', 'Sales payments (mobile)', 207),
|
||||||
|
('MOBILE.SALES.CUSTOMER', 'Customers (mobile)', 208),
|
||||||
|
('MOBILE.LOGISTICS.PLAN', 'Logistics plans (mobile)', 209);
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
ALTER TABLE "company_settings" ADD COLUMN "gps_interval_seconds" integer DEFAULT 5 NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "company_settings" ADD COLUMN "checkout_warning_radius_meters" integer DEFAULT 200 NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "timeline_footprints" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"employee_id" uuid NOT NULL,
|
||||||
|
"latitude" double precision NOT NULL,
|
||||||
|
"longitude" double precision NOT NULL,
|
||||||
|
"recorded_at" bigint NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "timeline_activities" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"employee_id" uuid NOT NULL,
|
||||||
|
"customer_id" uuid,
|
||||||
|
"visit_id" uuid,
|
||||||
|
"type" text NOT NULL,
|
||||||
|
"source_type" text NOT NULL,
|
||||||
|
"source_id" uuid NOT NULL,
|
||||||
|
"latitude" double precision NOT NULL,
|
||||||
|
"longitude" double precision NOT NULL,
|
||||||
|
"recorded_at" bigint NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "timeline_footprints" ADD CONSTRAINT "timeline_footprints_employee_id_employees_id_fk" FOREIGN KEY ("employee_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "timeline_activities" ADD CONSTRAINT "timeline_activities_employee_id_employees_id_fk" FOREIGN KEY ("employee_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "timeline_activities" ADD CONSTRAINT "timeline_activities_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "timeline_activities" ADD CONSTRAINT "timeline_activities_visit_id_visits_id_fk" FOREIGN KEY ("visit_id") REFERENCES "public"."visits"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "timeline_footprints_employee_recorded_idx" ON "timeline_footprints" USING btree ("employee_id","recorded_at");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "timeline_activities_employee_recorded_idx" ON "timeline_activities" USING btree ("employee_id","recorded_at");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "timeline_activities_visit_id_idx" ON "timeline_activities" USING btree ("visit_id");
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES
|
||||||
|
('ADMIN.SALES.ACTIVITIES.TIMELINE', 'Sales timeline', 119),
|
||||||
|
('MOBILE.SALES.TIMELINE', 'Sales timeline (mobile)', 210);
|
||||||
@@ -106,6 +106,27 @@
|
|||||||
"when": 1787562000000,
|
"when": 1787562000000,
|
||||||
"tag": "0014_sales_invoice_location",
|
"tag": "0014_sales_invoice_location",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -4,13 +4,22 @@ import type { PrivilegeAction } from '../../modules/privileges/privilege-action'
|
|||||||
export const REQUIRE_PRIVILEGE_KEY = 'requirePrivilege';
|
export const REQUIRE_PRIVILEGE_KEY = 'requirePrivilege';
|
||||||
|
|
||||||
export type RequirePrivilegeMeta = {
|
export type RequirePrivilegeMeta = {
|
||||||
readonly key: string;
|
readonly keys: readonly string[];
|
||||||
readonly action: PrivilegeAction;
|
readonly action: PrivilegeAction;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Marks a handler as requiring a privilege matrix cell to be true. */
|
function normalizePrivilegeKeys(
|
||||||
export const RequirePrivilege = (key: string, action: PrivilegeAction) =>
|
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, {
|
SetMetadata(REQUIRE_PRIVILEGE_KEY, {
|
||||||
key,
|
keys: normalizePrivilegeKeys(keys),
|
||||||
action,
|
action,
|
||||||
} satisfies RequirePrivilegeMeta);
|
} satisfies RequirePrivilegeMeta);
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ import {
|
|||||||
import { PrivilegesGuard } from './privileges.guard';
|
import { PrivilegesGuard } from './privileges.guard';
|
||||||
|
|
||||||
describe('PrivilegesGuard', () => {
|
describe('PrivilegesGuard', () => {
|
||||||
const checkPermission = jest.fn();
|
const checkAnyPermission = jest.fn();
|
||||||
const getAllAndOverride = jest.fn();
|
const getAllAndOverride = jest.fn();
|
||||||
const reflector = {
|
const reflector = {
|
||||||
getAllAndOverride,
|
getAllAndOverride,
|
||||||
} as unknown as Reflector;
|
} as unknown as Reflector;
|
||||||
|
|
||||||
const guard = new PrivilegesGuard(reflector, {
|
const guard = new PrivilegesGuard(reflector, {
|
||||||
checkPermission,
|
checkAnyPermission,
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
const user: AuthUser = {
|
const user: AuthUser = {
|
||||||
@@ -46,26 +46,48 @@ describe('PrivilegesGuard', () => {
|
|||||||
it('allows when no RequirePrivilege metadata', async () => {
|
it('allows when no RequirePrivilege metadata', async () => {
|
||||||
getAllAndOverride.mockReturnValue(undefined);
|
getAllAndOverride.mockReturnValue(undefined);
|
||||||
await expect(guard.canActivate(createContext(user))).resolves.toBe(true);
|
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 () => {
|
it('allows when permission value is true for a single key', async () => {
|
||||||
const meta: RequirePrivilegeMeta = { key: 'PRIVILEGES', action: 'view' };
|
const meta: RequirePrivilegeMeta = {
|
||||||
|
keys: ['ADMIN.SETTINGS.USER.PRIVILEGES'],
|
||||||
|
action: 'view',
|
||||||
|
};
|
||||||
getAllAndOverride.mockReturnValue(meta);
|
getAllAndOverride.mockReturnValue(meta);
|
||||||
checkPermission.mockResolvedValue(true);
|
checkAnyPermission.mockResolvedValue(true);
|
||||||
|
|
||||||
await expect(guard.canActivate(createContext(user))).resolves.toBe(true);
|
await expect(guard.canActivate(createContext(user))).resolves.toBe(true);
|
||||||
expect(checkPermission).toHaveBeenCalledWith(
|
expect(checkAnyPermission).toHaveBeenCalledWith(
|
||||||
'user-1',
|
'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',
|
'view',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('forbids when permission is false or missing', async () => {
|
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);
|
getAllAndOverride.mockReturnValue(meta);
|
||||||
checkPermission.mockResolvedValue(false);
|
checkAnyPermission.mockResolvedValue(false);
|
||||||
|
|
||||||
await expect(guard.canActivate(createContext(user))).rejects.toBeInstanceOf(
|
await expect(guard.canActivate(createContext(user))).rejects.toBeInstanceOf(
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
@@ -73,17 +95,23 @@ describe('PrivilegesGuard', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('skips privilege lookup when user is superadmin', async () => {
|
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);
|
getAllAndOverride.mockReturnValue(meta);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
guard.canActivate(createContext({ ...user, isSuperadmin: true })),
|
guard.canActivate(createContext({ ...user, isSuperadmin: true })),
|
||||||
).resolves.toBe(true);
|
).resolves.toBe(true);
|
||||||
expect(checkPermission).not.toHaveBeenCalled();
|
expect(checkAnyPermission).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('unauthorized when metadata present but no user', async () => {
|
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);
|
getAllAndOverride.mockReturnValue(meta);
|
||||||
|
|
||||||
await expect(guard.canActivate(createContext())).rejects.toBeInstanceOf(
|
await expect(guard.canActivate(createContext())).rejects.toBeInstanceOf(
|
||||||
|
|||||||
@@ -39,9 +39,9 @@ export class PrivilegesGuard implements CanActivate {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowed = await this.privilegesService.checkPermission(
|
const allowed = await this.privilegesService.checkAnyPermission(
|
||||||
user.id,
|
user.id,
|
||||||
required.key,
|
required.keys,
|
||||||
required.action,
|
required.action,
|
||||||
);
|
);
|
||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ describe('loadEnv', () => {
|
|||||||
expect(env.REFRESH_TOKEN_EXPIRES_IN_MS).toBe(7 * 24 * 60 * 60 * 1000);
|
expect(env.REFRESH_TOKEN_EXPIRES_IN_MS).toBe(7 * 24 * 60 * 60 * 1000);
|
||||||
expect(env.BCRYPT_SALT_ROUNDS).toBe(10);
|
expect(env.BCRYPT_SALT_ROUNDS).toBe(10);
|
||||||
expect(env.DEFAULT_TIMEZONE).toBe('GMT+7');
|
expect(env.DEFAULT_TIMEZONE).toBe('GMT+7');
|
||||||
|
expect(env.SKIP_GPS_VALIDATION).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws when DATABASE_URL is missing', () => {
|
it('throws when DATABASE_URL is missing', () => {
|
||||||
@@ -75,4 +76,33 @@ describe('loadEnv', () => {
|
|||||||
}),
|
}),
|
||||||
).toThrow('must match');
|
).toThrow('must match');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('enables SKIP_GPS_VALIDATION outside production', () => {
|
||||||
|
const env = loadEnv({
|
||||||
|
...valid,
|
||||||
|
SKIP_GPS_VALIDATION: 'true',
|
||||||
|
NODE_ENV: 'development',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(env.SKIP_GPS_VALIDATION).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects SKIP_GPS_VALIDATION in production', () => {
|
||||||
|
expect(() =>
|
||||||
|
loadEnv({
|
||||||
|
...valid,
|
||||||
|
SKIP_GPS_VALIDATION: 'true',
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
}),
|
||||||
|
).toThrow('cannot be enabled in production');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid SKIP_GPS_VALIDATION values', () => {
|
||||||
|
expect(() =>
|
||||||
|
loadEnv({
|
||||||
|
...valid,
|
||||||
|
SKIP_GPS_VALIDATION: 'yes',
|
||||||
|
}),
|
||||||
|
).toThrow('must be true or false');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type AppEnv = {
|
|||||||
REFRESH_TOKEN_EXPIRES_IN_MS: number;
|
REFRESH_TOKEN_EXPIRES_IN_MS: number;
|
||||||
BCRYPT_SALT_ROUNDS: number;
|
BCRYPT_SALT_ROUNDS: number;
|
||||||
DEFAULT_TIMEZONE: string;
|
DEFAULT_TIMEZONE: string;
|
||||||
|
SKIP_GPS_VALIDATION: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
@@ -37,6 +38,24 @@ function requireSecret(name: string, value: string | undefined): string {
|
|||||||
return secret;
|
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(
|
function parsePositiveInt(
|
||||||
name: string,
|
name: string,
|
||||||
value: string | undefined,
|
value: string | undefined,
|
||||||
@@ -88,6 +107,15 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
|
|||||||
FIFTEEN_MINUTES_MS,
|
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 {
|
return {
|
||||||
PORT: parsePositiveInt('PORT', source.PORT, 3000),
|
PORT: parsePositiveInt('PORT', source.PORT, 3000),
|
||||||
DATABASE_URL: requireString('DATABASE_URL', source.DATABASE_URL),
|
DATABASE_URL: requireString('DATABASE_URL', source.DATABASE_URL),
|
||||||
@@ -108,6 +136,7 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
|
|||||||
10,
|
10,
|
||||||
),
|
),
|
||||||
DEFAULT_TIMEZONE: source.DEFAULT_TIMEZONE?.trim() || 'GMT+7',
|
DEFAULT_TIMEZONE: source.DEFAULT_TIMEZONE?.trim() || 'GMT+7',
|
||||||
|
SKIP_GPS_VALIDATION: skipGpsValidation,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { bigint, index, pgTable, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
|
||||||
|
import { branches } from './branches-table';
|
||||||
|
import { checkInColumns, checkOutColumns } from './checkpoint-columns';
|
||||||
|
import { employees } from './employees-table';
|
||||||
|
import { primaryEntityColumns } from './primary-entity-columns';
|
||||||
|
import { users } from './schema';
|
||||||
|
|
||||||
|
export const attendances = pgTable(
|
||||||
|
'attendances',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||||
|
employeeId: uuid('employee_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => employees.id, { onDelete: 'restrict' }),
|
||||||
|
branchId: uuid('branch_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => branches.id, { onDelete: 'restrict' }),
|
||||||
|
date: bigint('date', { mode: 'number' }).notNull(),
|
||||||
|
...checkInColumns,
|
||||||
|
...checkOutColumns,
|
||||||
|
...primaryEntityColumns(users),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex('attendances_employee_date_live_unique')
|
||||||
|
.on(t.employeeId, t.date)
|
||||||
|
.where(sql`${t.status} <> 'archived'`),
|
||||||
|
uniqueIndex('attendances_employee_open_unique')
|
||||||
|
.on(t.employeeId)
|
||||||
|
.where(sql`${t.checkOutAt} IS NULL AND ${t.status} <> 'archived'`),
|
||||||
|
index('attendances_employee_id_idx').on(t.employeeId),
|
||||||
|
index('attendances_branch_id_idx').on(t.branchId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export type AttendanceRow = typeof attendances.$inferSelect;
|
||||||
|
export type NewAttendanceRow = typeof attendances.$inferInsert;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { bigint, doublePrecision, integer, text } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
export const checkInColumns = {
|
||||||
|
checkInAt: bigint('check_in_at', { mode: 'number' }).notNull(),
|
||||||
|
checkInMethod: text('check_in_method').notNull(),
|
||||||
|
checkInLatitude: doublePrecision('check_in_latitude').notNull(),
|
||||||
|
checkInLongitude: doublePrecision('check_in_longitude').notNull(),
|
||||||
|
checkInPhotoUrl: text('check_in_photo_url'),
|
||||||
|
checkInDistanceMeters: integer('check_in_distance_meters'),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const checkOutColumns = {
|
||||||
|
checkOutAt: bigint('check_out_at', { mode: 'number' }),
|
||||||
|
checkOutMethod: text('check_out_method'),
|
||||||
|
checkOutLatitude: doublePrecision('check_out_latitude'),
|
||||||
|
checkOutLongitude: doublePrecision('check_out_longitude'),
|
||||||
|
checkOutPhotoUrl: text('check_out_photo_url'),
|
||||||
|
checkOutDistanceMeters: integer('check_out_distance_meters'),
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { bigint, pgTable, uuid } from 'drizzle-orm/pg-core';
|
import { bigint, integer, pgTable, uuid } from 'drizzle-orm/pg-core';
|
||||||
import { primaryEntityColumns } from './primary-entity-columns';
|
import { primaryEntityColumns } from './primary-entity-columns';
|
||||||
import { users } from './schema';
|
import { users } from './schema';
|
||||||
|
|
||||||
@@ -8,6 +8,11 @@ import { users } from './schema';
|
|||||||
export const companySettings = pgTable('company_settings', {
|
export const companySettings = pgTable('company_settings', {
|
||||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||||
cycleStartDate: bigint('cycle_start_date', { mode: 'number' }).notNull(),
|
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),
|
...primaryEntityColumns(users),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -247,6 +247,24 @@ export {
|
|||||||
type PlanPackingSlipRow,
|
type PlanPackingSlipRow,
|
||||||
type PlanRow,
|
type PlanRow,
|
||||||
} from './plans-table';
|
} 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 {
|
export {
|
||||||
reportBookmarks,
|
reportBookmarks,
|
||||||
type NewReportBookmarkRow,
|
type NewReportBookmarkRow,
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import {
|
||||||
|
bigint,
|
||||||
|
doublePrecision,
|
||||||
|
index,
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
uuid,
|
||||||
|
} from 'drizzle-orm/pg-core';
|
||||||
|
import { customers } from './customers-table';
|
||||||
|
import { employees } from './employees-table';
|
||||||
|
import { visits } from './visits-table';
|
||||||
|
|
||||||
|
export const TIMELINE_ACTIVITY_TYPES = [
|
||||||
|
'branch_check_in',
|
||||||
|
'branch_check_out',
|
||||||
|
'customer_check_in',
|
||||||
|
'customer_check_out',
|
||||||
|
'sales_order_created',
|
||||||
|
'sales_request_created',
|
||||||
|
'sales_payment_created',
|
||||||
|
'customer_created',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type TimelineActivityType = (typeof TIMELINE_ACTIVITY_TYPES)[number];
|
||||||
|
|
||||||
|
export const timelineActivities = pgTable(
|
||||||
|
'timeline_activities',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||||
|
employeeId: uuid('employee_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => employees.id, { onDelete: 'restrict' }),
|
||||||
|
customerId: uuid('customer_id').references(() => customers.id, {
|
||||||
|
onDelete: 'set null',
|
||||||
|
}),
|
||||||
|
visitId: uuid('visit_id').references(() => visits.id, {
|
||||||
|
onDelete: 'set null',
|
||||||
|
}),
|
||||||
|
type: text('type').notNull(),
|
||||||
|
sourceType: text('source_type').notNull(),
|
||||||
|
sourceId: uuid('source_id').notNull(),
|
||||||
|
latitude: doublePrecision('latitude').notNull(),
|
||||||
|
longitude: doublePrecision('longitude').notNull(),
|
||||||
|
recordedAt: bigint('recorded_at', { mode: 'number' }).notNull(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index('timeline_activities_employee_recorded_idx').on(
|
||||||
|
t.employeeId,
|
||||||
|
t.recordedAt,
|
||||||
|
),
|
||||||
|
index('timeline_activities_visit_id_idx').on(t.visitId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export type TimelineActivityRow = typeof timelineActivities.$inferSelect;
|
||||||
|
export type NewTimelineActivityRow = typeof timelineActivities.$inferInsert;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { bigint, doublePrecision, index, pgTable, uuid } from 'drizzle-orm/pg-core';
|
||||||
|
import { employees } from './employees-table';
|
||||||
|
|
||||||
|
export const timelineFootprints = pgTable(
|
||||||
|
'timeline_footprints',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||||
|
employeeId: uuid('employee_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => employees.id, { onDelete: 'restrict' }),
|
||||||
|
latitude: doublePrecision('latitude').notNull(),
|
||||||
|
longitude: doublePrecision('longitude').notNull(),
|
||||||
|
recordedAt: bigint('recorded_at', { mode: 'number' }).notNull(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index('timeline_footprints_employee_recorded_idx').on(
|
||||||
|
t.employeeId,
|
||||||
|
t.recordedAt,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export type TimelineFootprintRow = typeof timelineFootprints.$inferSelect;
|
||||||
|
export type NewTimelineFootprintRow = typeof timelineFootprints.$inferInsert;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { bigint, index, pgTable, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
|
||||||
|
import { attendances } from './attendances-table';
|
||||||
|
import { checkInColumns, checkOutColumns } from './checkpoint-columns';
|
||||||
|
import { customers } from './customers-table';
|
||||||
|
import { employees } from './employees-table';
|
||||||
|
import { planDestinations, plans } from './plans-table';
|
||||||
|
import { primaryEntityColumns } from './primary-entity-columns';
|
||||||
|
import { users } from './schema';
|
||||||
|
|
||||||
|
export const visits = pgTable(
|
||||||
|
'visits',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||||
|
employeeId: uuid('employee_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => employees.id, { onDelete: 'restrict' }),
|
||||||
|
customerId: uuid('customer_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => customers.id, { onDelete: 'restrict' }),
|
||||||
|
attendanceId: uuid('attendance_id').references(() => attendances.id, {
|
||||||
|
onDelete: 'set null',
|
||||||
|
}),
|
||||||
|
planId: uuid('plan_id').references(() => plans.id, {
|
||||||
|
onDelete: 'set null',
|
||||||
|
}),
|
||||||
|
planDestinationId: uuid('plan_destination_id').references(
|
||||||
|
() => planDestinations.id,
|
||||||
|
{ onDelete: 'set null' },
|
||||||
|
),
|
||||||
|
date: bigint('date', { mode: 'number' }).notNull(),
|
||||||
|
...checkInColumns,
|
||||||
|
...checkOutColumns,
|
||||||
|
...primaryEntityColumns(users),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex('visits_employee_open_unique')
|
||||||
|
.on(t.employeeId)
|
||||||
|
.where(sql`${t.checkOutAt} IS NULL AND ${t.status} <> 'archived'`),
|
||||||
|
index('visits_employee_id_idx').on(t.employeeId),
|
||||||
|
index('visits_customer_id_idx').on(t.customerId),
|
||||||
|
index('visits_attendance_id_idx').on(t.attendanceId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export type VisitRow = typeof visits.$inferSelect;
|
||||||
|
export type NewVisitRow = typeof visits.$inferInsert;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { PrivilegesService } from '../privileges/privileges.service';
|
import { PrivilegesService } from '../privileges/privileges.service';
|
||||||
|
import { EmployeesService } from '../configuration/employees/employees.service';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
@@ -13,6 +14,9 @@ describe('AuthController', () => {
|
|||||||
let privilegesService: jest.Mocked<
|
let privilegesService: jest.Mocked<
|
||||||
Pick<PrivilegesService, 'findPrivilegeSummary' | 'getPermissionsMap'>
|
Pick<PrivilegesService, 'findPrivilegeSummary' | 'getPermissionsMap'>
|
||||||
>;
|
>;
|
||||||
|
let employeesService: jest.Mocked<
|
||||||
|
Pick<EmployeesService, 'findRelationByUserId'>
|
||||||
|
>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
authService = {
|
authService = {
|
||||||
@@ -43,6 +47,9 @@ describe('AuthController', () => {
|
|||||||
findPrivilegeSummary: jest.fn(),
|
findPrivilegeSummary: jest.fn(),
|
||||||
getPermissionsMap: jest.fn(),
|
getPermissionsMap: jest.fn(),
|
||||||
};
|
};
|
||||||
|
employeesService = {
|
||||||
|
findRelationByUserId: jest.fn().mockResolvedValue(null),
|
||||||
|
};
|
||||||
|
|
||||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
@@ -50,6 +57,7 @@ describe('AuthController', () => {
|
|||||||
{ provide: AuthService, useValue: authService },
|
{ provide: AuthService, useValue: authService },
|
||||||
{ provide: UsersService, useValue: usersService },
|
{ provide: UsersService, useValue: usersService },
|
||||||
{ provide: PrivilegesService, useValue: privilegesService },
|
{ provide: PrivilegesService, useValue: privilegesService },
|
||||||
|
{ provide: EmployeesService, useValue: employeesService },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
@@ -91,6 +99,7 @@ describe('AuthController', () => {
|
|||||||
username: 'alice',
|
username: 'alice',
|
||||||
isSuperadmin: false,
|
isSuperadmin: false,
|
||||||
privilege: null,
|
privilege: null,
|
||||||
|
employee: null,
|
||||||
permissions: {},
|
permissions: {},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -109,7 +118,7 @@ describe('AuthController', () => {
|
|||||||
status: 'active',
|
status: 'active',
|
||||||
});
|
});
|
||||||
privilegesService.getPermissionsMap.mockResolvedValue({
|
privilegesService.getPermissionsMap.mockResolvedValue({
|
||||||
PRIVILEGES: {
|
'ADMIN.SETTINGS.USER.PRIVILEGES': {
|
||||||
view: true,
|
view: true,
|
||||||
create: true,
|
create: true,
|
||||||
update: true,
|
update: true,
|
||||||
@@ -128,7 +137,7 @@ describe('AuthController', () => {
|
|||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
privilege: { id: 'priv-1', code: 'ADMIN' },
|
privilege: { id: 'priv-1', code: 'ADMIN' },
|
||||||
permissions: {
|
permissions: {
|
||||||
PRIVILEGES: expect.objectContaining({ view: true }),
|
'ADMIN.SETTINGS.USER.PRIVILEGES': expect.objectContaining({ view: true }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -153,6 +162,7 @@ describe('AuthController', () => {
|
|||||||
username: 'alice',
|
username: 'alice',
|
||||||
isSuperadmin: true,
|
isSuperadmin: true,
|
||||||
privilege: null,
|
privilege: null,
|
||||||
|
employee: null,
|
||||||
permissions: {},
|
permissions: {},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
|||||||
import { Public } from '../../common/decorators/public.decorator';
|
import { Public } from '../../common/decorators/public.decorator';
|
||||||
import { BEARER_AUTH_NAME } from '../../common/swagger/setup-swagger';
|
import { BEARER_AUTH_NAME } from '../../common/swagger/setup-swagger';
|
||||||
import { PrivilegesService } from '../privileges/privileges.service';
|
import { PrivilegesService } from '../privileges/privileges.service';
|
||||||
|
import { EmployeesService } from '../configuration/employees/employees.service';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +36,7 @@ export class AuthController {
|
|||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
private readonly usersService: UsersService,
|
private readonly usersService: UsersService,
|
||||||
private readonly privilegesService: PrivilegesService,
|
private readonly privilegesService: PrivilegesService,
|
||||||
|
private readonly employeesService: EmployeesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@@ -97,12 +99,15 @@ export class AuthController {
|
|||||||
async me(@CurrentUser() user: AuthUser): Promise<MeResponseDto> {
|
async me(@CurrentUser() user: AuthUser): Promise<MeResponseDto> {
|
||||||
const full = await this.usersService.findById(user.id);
|
const full = await this.usersService.findById(user.id);
|
||||||
const isSuperadmin = full?.isSuperadmin ?? user.isSuperadmin;
|
const isSuperadmin = full?.isSuperadmin ?? user.isSuperadmin;
|
||||||
|
const employee = await this.employeesService.findRelationByUserId(user.id);
|
||||||
|
|
||||||
if (!full?.privilegeId) {
|
if (!full?.privilegeId) {
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
isSuperadmin,
|
isSuperadmin,
|
||||||
privilege: null,
|
privilege: null,
|
||||||
|
employee,
|
||||||
permissions: {},
|
permissions: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -119,6 +124,7 @@ export class AuthController {
|
|||||||
username: user.username,
|
username: user.username,
|
||||||
isSuperadmin,
|
isSuperadmin,
|
||||||
privilege,
|
privilege,
|
||||||
|
employee,
|
||||||
permissions,
|
permissions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
|||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
import { PrivilegesGuard } from '../../common/guards/privileges.guard';
|
import { PrivilegesGuard } from '../../common/guards/privileges.guard';
|
||||||
import { PrivilegesModule } from '../privileges/privileges.module';
|
import { PrivilegesModule } from '../privileges/privileges.module';
|
||||||
|
import { EmployeesModule } from '../configuration/employees/employees.module';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
@@ -17,6 +18,7 @@ import { JwtStrategy } from './strategies/jwt.strategy';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
UsersModule,
|
UsersModule,
|
||||||
|
EmployeesModule,
|
||||||
PrivilegesModule,
|
PrivilegesModule,
|
||||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
|
|||||||
@@ -90,6 +90,20 @@ export class MePrivilegeDto {
|
|||||||
code!: string;
|
code!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class MeEmployeeDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'sales' })
|
||||||
|
position!: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class MeResponseDto {
|
export class MeResponseDto {
|
||||||
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
|
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
|
||||||
id!: string;
|
id!: string;
|
||||||
@@ -103,10 +117,13 @@ export class MeResponseDto {
|
|||||||
@ApiProperty({ type: MePrivilegeDto, nullable: true })
|
@ApiProperty({ type: MePrivilegeDto, nullable: true })
|
||||||
privilege!: MePrivilegeDto | null;
|
privilege!: MePrivilegeDto | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: MeEmployeeDto, nullable: true })
|
||||||
|
employee!: MeEmployeeDto | null;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Permission matrix keyed by privilege key code',
|
description: 'Permission matrix keyed by privilege key code',
|
||||||
example: {
|
example: {
|
||||||
PRIVILEGES: {
|
'ADMIN.SETTINGS.USER.PRIVILEGES': {
|
||||||
view: true,
|
view: true,
|
||||||
create: false,
|
create: false,
|
||||||
update: false,
|
update: false,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
|||||||
import { BranchDto, ListBranchesQueryDto } from './dto/branch.dto';
|
import { BranchDto, ListBranchesQueryDto } from './dto/branch.dto';
|
||||||
import { BranchesService } from './branches.service';
|
import { BranchesService } from './branches.service';
|
||||||
|
|
||||||
export const BRANCH_PRIVILEGE_KEY = 'CONFIGURATION.BRANCH';
|
export const BRANCH_PRIVILEGE_KEY = 'ADMIN.SETTINGS.DATA.BRANCH';
|
||||||
|
|
||||||
@ApiTags('branches')
|
@ApiTags('branches')
|
||||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
@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 { CustomerDto, ListCustomersQueryDto } from './dto/customer.dto';
|
||||||
import { CustomersService } from './customers.service';
|
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')
|
@ApiTags('customers')
|
||||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||||
@@ -28,7 +31,7 @@ export class CustomersReadController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@Pagination()
|
@Pagination()
|
||||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'view')
|
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'view')
|
||||||
@ApiOperation({ summary: 'List customers' })
|
@ApiOperation({ summary: 'List customers' })
|
||||||
@ApiOkResponse({
|
@ApiOkResponse({
|
||||||
schema: {
|
schema: {
|
||||||
@@ -50,7 +53,7 @@ export class CustomersReadController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'view')
|
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'view')
|
||||||
@ApiOperation({ summary: 'Get customer detail' })
|
@ApiOperation({ summary: 'Get customer detail' })
|
||||||
@ApiOkResponse({ type: CustomerDto })
|
@ApiOkResponse({ type: CustomerDto })
|
||||||
@ApiNotFoundResponse()
|
@ApiNotFoundResponse()
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
|||||||
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
||||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||||
import { isAllowedCsvUpload } from './customer-fields';
|
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 { CustomersService } from './customers.service';
|
||||||
import {
|
import {
|
||||||
BulkIdsDto,
|
BulkIdsDto,
|
||||||
@@ -49,7 +49,7 @@ export class CustomersWriteController {
|
|||||||
constructor(private readonly customersService: CustomersService) {}
|
constructor(private readonly customersService: CustomersService) {}
|
||||||
|
|
||||||
@Post('import')
|
@Post('import')
|
||||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'import')
|
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'import')
|
||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
FileInterceptor('file', {
|
FileInterceptor('file', {
|
||||||
limits: { fileSize: 1_048_576 },
|
limits: { fileSize: 1_048_576 },
|
||||||
@@ -88,7 +88,7 @@ export class CustomersWriteController {
|
|||||||
|
|
||||||
@Post('bulk-delete')
|
@Post('bulk-delete')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'delete')
|
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'delete')
|
||||||
@ApiOperation({ summary: 'Bulk delete customers' })
|
@ApiOperation({ summary: 'Bulk delete customers' })
|
||||||
@ApiOkResponse({
|
@ApiOkResponse({
|
||||||
schema: { properties: { deleted: { type: 'number' } } },
|
schema: { properties: { deleted: { type: 'number' } } },
|
||||||
@@ -101,7 +101,7 @@ export class CustomersWriteController {
|
|||||||
|
|
||||||
@Post('bulk-status')
|
@Post('bulk-status')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update')
|
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update')
|
||||||
@ApiOperation({ summary: 'Bulk update customer status' })
|
@ApiOperation({ summary: 'Bulk update customer status' })
|
||||||
@ApiOkResponse({
|
@ApiOkResponse({
|
||||||
schema: { properties: { updated: { type: 'number' } } },
|
schema: { properties: { updated: { type: 'number' } } },
|
||||||
@@ -116,7 +116,7 @@ export class CustomersWriteController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'create')
|
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'create')
|
||||||
@ApiOperation({ summary: 'Create customer' })
|
@ApiOperation({ summary: 'Create customer' })
|
||||||
@ApiCreatedResponse({ type: CustomerDto })
|
@ApiCreatedResponse({ type: CustomerDto })
|
||||||
@ApiUnauthorizedResponse()
|
@ApiUnauthorizedResponse()
|
||||||
@@ -132,7 +132,7 @@ export class CustomersWriteController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/contacts')
|
@Post(':id/contacts')
|
||||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update')
|
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update')
|
||||||
@ApiOperation({ summary: 'Add a customer contact' })
|
@ApiOperation({ summary: 'Add a customer contact' })
|
||||||
@ApiOkResponse({ type: CustomerDto })
|
@ApiOkResponse({ type: CustomerDto })
|
||||||
@ApiNotFoundResponse()
|
@ApiNotFoundResponse()
|
||||||
@@ -147,7 +147,7 @@ export class CustomersWriteController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/contacts/:contactId')
|
@Patch(':id/contacts/:contactId')
|
||||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update')
|
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update')
|
||||||
@ApiOperation({ summary: 'Update a customer contact' })
|
@ApiOperation({ summary: 'Update a customer contact' })
|
||||||
@ApiOkResponse({ type: CustomerDto })
|
@ApiOkResponse({ type: CustomerDto })
|
||||||
@ApiNotFoundResponse()
|
@ApiNotFoundResponse()
|
||||||
@@ -167,7 +167,7 @@ export class CustomersWriteController {
|
|||||||
|
|
||||||
@Delete(':id/contacts/:contactId')
|
@Delete(':id/contacts/:contactId')
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update')
|
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update')
|
||||||
@ApiOperation({ summary: 'Delete a customer contact' })
|
@ApiOperation({ summary: 'Delete a customer contact' })
|
||||||
@ApiNoContentResponse()
|
@ApiNoContentResponse()
|
||||||
@ApiNotFoundResponse()
|
@ApiNotFoundResponse()
|
||||||
@@ -181,7 +181,7 @@ export class CustomersWriteController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/status')
|
@Patch(':id/status')
|
||||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update')
|
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update')
|
||||||
@ApiOperation({ summary: 'Update customer status' })
|
@ApiOperation({ summary: 'Update customer status' })
|
||||||
@ApiOkResponse({ type: CustomerDto })
|
@ApiOkResponse({ type: CustomerDto })
|
||||||
@ApiNotFoundResponse()
|
@ApiNotFoundResponse()
|
||||||
@@ -196,7 +196,7 @@ export class CustomersWriteController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update')
|
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update')
|
||||||
@ApiOperation({ summary: 'Update customer (not status)' })
|
@ApiOperation({ summary: 'Update customer (not status)' })
|
||||||
@ApiOkResponse({ type: CustomerDto })
|
@ApiOkResponse({ type: CustomerDto })
|
||||||
@ApiNotFoundResponse()
|
@ApiNotFoundResponse()
|
||||||
@@ -215,7 +215,7 @@ export class CustomersWriteController {
|
|||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'delete')
|
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'delete')
|
||||||
@ApiOperation({ summary: 'Delete customer' })
|
@ApiOperation({ summary: 'Delete customer' })
|
||||||
@ApiNoContentResponse()
|
@ApiNoContentResponse()
|
||||||
@ApiNotFoundResponse()
|
@ApiNotFoundResponse()
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
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 { CustomersReadController } from './customers-read.controller';
|
||||||
import { CustomersWriteController } from './customers-write.controller';
|
import { CustomersWriteController } from './customers-write.controller';
|
||||||
import { CustomersRepository } from './customers.repository';
|
import { CustomersRepository } from './customers.repository';
|
||||||
import { CustomersService } from './customers.service';
|
import { CustomersService } from './customers.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [EmployeesModule, TimelineModule],
|
||||||
controllers: [CustomersReadController, CustomersWriteController],
|
controllers: [CustomersReadController, CustomersWriteController],
|
||||||
providers: [CustomersRepository, CustomersService],
|
providers: [CustomersRepository, CustomersService],
|
||||||
exports: [CustomersService],
|
exports: [CustomersService],
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import {
|
|||||||
parseCsvRecord,
|
parseCsvRecord,
|
||||||
} from './customer-fields';
|
} from './customer-fields';
|
||||||
import { CustomersRepository } from './customers.repository';
|
import { CustomersRepository } from './customers.repository';
|
||||||
|
import { EmployeesService } from '../employees/employees.service';
|
||||||
|
import { TimelineActivitiesService } from '../../field/timeline/timeline-activities.service';
|
||||||
|
|
||||||
export type ListCustomersQuery = {
|
export type ListCustomersQuery = {
|
||||||
readonly code?: string;
|
readonly code?: string;
|
||||||
@@ -73,7 +75,11 @@ const CSV_REQUIRED_HEADERS = ['code', 'name', 'phone', 'address'] as const;
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CustomersService {
|
export class CustomersService {
|
||||||
constructor(private readonly customersRepository: CustomersRepository) {}
|
constructor(
|
||||||
|
private readonly customersRepository: CustomersRepository,
|
||||||
|
private readonly employeesService: EmployeesService,
|
||||||
|
private readonly timelineActivitiesService: TimelineActivitiesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async list(
|
async list(
|
||||||
query: ListCustomersQuery,
|
query: ListCustomersQuery,
|
||||||
@@ -133,6 +139,16 @@ export class CustomersService {
|
|||||||
const created = await this.customersRepository.create(
|
const created = await this.customersRepository.create(
|
||||||
this.toCreateInput(input),
|
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);
|
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 { DivisionDto, ListDivisionsQueryDto } from './dto/division.dto';
|
||||||
import { DivisionsService } from './divisions.service';
|
import { DivisionsService } from './divisions.service';
|
||||||
|
|
||||||
export const DIVISION_PRIVILEGE_KEY = 'CONFIGURATION.DIVISION';
|
export const DIVISION_PRIVILEGE_KEY = 'ADMIN.SETTINGS.DATA.DIVISION';
|
||||||
|
|
||||||
@ApiTags('divisions')
|
@ApiTags('divisions')
|
||||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
@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 { EmployeeDto, ListEmployeesQueryDto } from './dto/employee.dto';
|
||||||
import { EmployeesService } from './employees.service';
|
import { EmployeesService } from './employees.service';
|
||||||
|
|
||||||
export const EMPLOYEE_PRIVILEGE_KEY = 'CONFIGURATION.EMPLOYEE';
|
export const EMPLOYEE_PRIVILEGE_KEY = 'ADMIN.SALES.DATA.EMPLOYEE';
|
||||||
|
|
||||||
@ApiTags('employees')
|
@ApiTags('employees')
|
||||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||||
|
|||||||
@@ -117,6 +117,32 @@ export class EmployeesService {
|
|||||||
return this.toListItem(employee);
|
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: {
|
async create(input: {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
|||||||
import { ProductDto, ListProductsQueryDto } from './dto/product.dto';
|
import { ProductDto, ListProductsQueryDto } from './dto/product.dto';
|
||||||
import { ProductsService } from './products.service';
|
import { ProductsService } from './products.service';
|
||||||
|
|
||||||
export const PRODUCT_PRIVILEGE_KEY = 'CONFIGURATION.PRODUCT';
|
export const PRODUCT_PRIVILEGE_KEY = 'ADMIN.SETTINGS.DATA.PRODUCT';
|
||||||
|
|
||||||
@ApiTags('products')
|
@ApiTags('products')
|
||||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
@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 employeesService = { findById: jest.fn(), findByCode: jest.fn() };
|
||||||
const branchesService = { findById: jest.fn(), findByCode: jest.fn() };
|
const branchesService = { findById: jest.fn(), findByCode: jest.fn() };
|
||||||
const customersService = { 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 now = DateTime.fromUnixMs(1_700_000_000_000);
|
||||||
const user: AuthUser = {
|
const user: AuthUser = {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
type WeekdaysInput,
|
type WeekdaysInput,
|
||||||
} from '../shared/field-fields';
|
} from '../shared/field-fields';
|
||||||
import {
|
import {
|
||||||
fieldPrivilegeKey,
|
fieldPrivilegeKeys,
|
||||||
isFieldPurpose,
|
isFieldPurpose,
|
||||||
WEEKDAY_NAMES,
|
WEEKDAY_NAMES,
|
||||||
type FieldPurpose,
|
type FieldPurpose,
|
||||||
@@ -485,9 +485,9 @@ export class CyclesService {
|
|||||||
}
|
}
|
||||||
const allowed: FieldPurpose[] = [];
|
const allowed: FieldPurpose[] = [];
|
||||||
for (const purpose of ['sales', 'logistics'] as const) {
|
for (const purpose of ['sales', 'logistics'] as const) {
|
||||||
const ok = await this.privilegesService.checkPermission(
|
const ok = await this.privilegesService.checkAnyPermission(
|
||||||
user.id,
|
user.id,
|
||||||
fieldPrivilegeKey('cycle', purpose),
|
fieldPrivilegeKeys('cycle', purpose),
|
||||||
action,
|
action,
|
||||||
);
|
);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
@@ -505,9 +505,9 @@ export class CyclesService {
|
|||||||
if (user.isSuperadmin) {
|
if (user.isSuperadmin) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ok = await this.privilegesService.checkPermission(
|
const ok = await this.privilegesService.checkAnyPermission(
|
||||||
user.id,
|
user.id,
|
||||||
fieldPrivilegeKey('cycle', purpose),
|
fieldPrivilegeKeys('cycle', purpose),
|
||||||
action,
|
action,
|
||||||
);
|
);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { EmployeesModule } from '../configuration/employees/employees.module';
|
|||||||
import { PrivilegesModule } from '../privileges/privileges.module';
|
import { PrivilegesModule } from '../privileges/privileges.module';
|
||||||
import { PackingSlipsModule } from '../sales/packing-slips/packing-slips.module';
|
import { PackingSlipsModule } from '../sales/packing-slips/packing-slips.module';
|
||||||
import { SalesInvoicesModule } from '../sales/sales-invoices/sales-invoices.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 { CyclesReadController } from './cycles/cycles-read.controller';
|
||||||
import { CyclesWriteController } from './cycles/cycles-write.controller';
|
import { CyclesWriteController } from './cycles/cycles-write.controller';
|
||||||
import { CyclesRepository } from './cycles/cycles.repository';
|
import { CyclesRepository } from './cycles/cycles.repository';
|
||||||
@@ -16,7 +20,12 @@ import { PlansService } from './plans/plans.service';
|
|||||||
import { CompanySettingsController } from './settings/company-settings.controller';
|
import { CompanySettingsController } from './settings/company-settings.controller';
|
||||||
import { CompanySettingsRepository } from './settings/company-settings.repository';
|
import { CompanySettingsRepository } from './settings/company-settings.repository';
|
||||||
import { CompanySettingsService } from './settings/company-settings.service';
|
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 { FieldPrivilegeGuard } from './shared/field-privilege.guard';
|
||||||
|
import { TimelineModule } from './timeline/timeline.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -26,23 +35,39 @@ import { FieldPrivilegeGuard } from './shared/field-privilege.guard';
|
|||||||
CustomersModule,
|
CustomersModule,
|
||||||
SalesInvoicesModule,
|
SalesInvoicesModule,
|
||||||
PackingSlipsModule,
|
PackingSlipsModule,
|
||||||
|
TimelineModule,
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
CompanySettingsController,
|
CompanySettingsController,
|
||||||
|
AttendancesReadController,
|
||||||
|
AttendancesWriteController,
|
||||||
CyclesReadController,
|
CyclesReadController,
|
||||||
CyclesWriteController,
|
CyclesWriteController,
|
||||||
PlansReadController,
|
PlansReadController,
|
||||||
PlansWriteController,
|
PlansWriteController,
|
||||||
|
VisitsReadController,
|
||||||
|
VisitsWriteController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
FieldPrivilegeGuard,
|
FieldPrivilegeGuard,
|
||||||
CompanySettingsRepository,
|
CompanySettingsRepository,
|
||||||
CompanySettingsService,
|
CompanySettingsService,
|
||||||
|
AttendancesRepository,
|
||||||
|
AttendancesService,
|
||||||
CyclesRepository,
|
CyclesRepository,
|
||||||
CyclesService,
|
CyclesService,
|
||||||
PlansRepository,
|
PlansRepository,
|
||||||
PlansService,
|
PlansService,
|
||||||
|
VisitsRepository,
|
||||||
|
VisitsService,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
CompanySettingsService,
|
||||||
|
AttendancesService,
|
||||||
|
CyclesService,
|
||||||
|
PlansService,
|
||||||
|
VisitsService,
|
||||||
|
TimelineModule,
|
||||||
],
|
],
|
||||||
exports: [CompanySettingsService, CyclesService, PlansService],
|
|
||||||
})
|
})
|
||||||
export class FieldModule {}
|
export class FieldModule {}
|
||||||
|
|||||||
@@ -79,9 +79,7 @@ export class PlansRepository {
|
|||||||
.limit(filters.limit)
|
.limit(filters.limit)
|
||||||
.offset(filters.offset);
|
.offset(filters.offset);
|
||||||
return {
|
return {
|
||||||
data: await this.hydrate(
|
data: await this.loadChildrenForRows(this.db, rows),
|
||||||
rows.map((row) => this.toDomain(row, [], [], [])),
|
|
||||||
),
|
|
||||||
total: Number(totalRows[0]?.total ?? 0),
|
total: Number(totalRows[0]?.total ?? 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -340,20 +338,61 @@ export class PlansRepository {
|
|||||||
executor: QueryExecutor,
|
executor: QueryExecutor,
|
||||||
row: PlanRow,
|
row: PlanRow,
|
||||||
): Promise<Plan> {
|
): Promise<Plan> {
|
||||||
const destinations = await executor
|
const [plan] = await this.loadChildrenForRows(executor, [row]);
|
||||||
.select()
|
if (!plan) {
|
||||||
.from(planDestinations)
|
throw new NotFoundException('Plan not found');
|
||||||
.where(eq(planDestinations.planId, row.id))
|
}
|
||||||
.orderBy(asc(planDestinations.sortOrder));
|
return plan;
|
||||||
const invoices = await executor
|
}
|
||||||
.select()
|
|
||||||
.from(planInvoices)
|
private async loadChildrenForRows(
|
||||||
.where(eq(planInvoices.planId, row.id));
|
executor: QueryExecutor,
|
||||||
const packingSlips = await executor
|
rows: PlanRow[],
|
||||||
.select()
|
): Promise<Plan[]> {
|
||||||
.from(planPackingSlips)
|
if (rows.length === 0) {
|
||||||
.where(eq(planPackingSlips.planId, row.id));
|
return [];
|
||||||
return this.hydrateOne(row, destinations, invoices, packingSlips);
|
}
|
||||||
|
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(
|
private async replaceChildren(
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ describe('PlansService', () => {
|
|||||||
markDraftsProcessed: jest.fn(),
|
markDraftsProcessed: jest.fn(),
|
||||||
};
|
};
|
||||||
const packingSlipsService = { findById: jest.fn() };
|
const packingSlipsService = { findById: jest.fn() };
|
||||||
const privilegesService = { checkPermission: jest.fn() };
|
const privilegesService = { checkPermission: jest.fn(), checkAnyPermission: jest.fn() };
|
||||||
|
|
||||||
const user: AuthUser = {
|
const user: AuthUser = {
|
||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { CyclesRepository } from '../cycles/cycles.repository';
|
|||||||
import { CompanySettingsService } from '../settings/company-settings.service';
|
import { CompanySettingsService } from '../settings/company-settings.service';
|
||||||
import {
|
import {
|
||||||
cycleNumberForDate,
|
cycleNumberForDate,
|
||||||
fieldPrivilegeKey,
|
fieldPrivilegeKeys,
|
||||||
isFieldPurpose,
|
isFieldPurpose,
|
||||||
noCycleMessage,
|
noCycleMessage,
|
||||||
type FieldPurpose,
|
type FieldPurpose,
|
||||||
@@ -615,9 +615,9 @@ export class PlansService {
|
|||||||
}
|
}
|
||||||
const allowed: FieldPurpose[] = [];
|
const allowed: FieldPurpose[] = [];
|
||||||
for (const purpose of ['sales', 'logistics'] as const) {
|
for (const purpose of ['sales', 'logistics'] as const) {
|
||||||
const ok = await this.privilegesService.checkPermission(
|
const ok = await this.privilegesService.checkAnyPermission(
|
||||||
user.id,
|
user.id,
|
||||||
fieldPrivilegeKey('plan', purpose),
|
fieldPrivilegeKeys('plan', purpose),
|
||||||
action,
|
action,
|
||||||
);
|
);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
@@ -635,9 +635,9 @@ export class PlansService {
|
|||||||
if (user.isSuperadmin) {
|
if (user.isSuperadmin) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ok = await this.privilegesService.checkPermission(
|
const ok = await this.privilegesService.checkAnyPermission(
|
||||||
user.id,
|
user.id,
|
||||||
fieldPrivilegeKey('plan', purpose),
|
fieldPrivilegeKeys('plan', purpose),
|
||||||
action,
|
action,
|
||||||
);
|
);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { Status } from '../../../common/value-objects/status/status';
|
|||||||
export type CompanySetting = {
|
export type CompanySetting = {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly cycleStartDate: DateTime;
|
readonly cycleStartDate: DateTime;
|
||||||
|
readonly checkInRadiusMeters: number;
|
||||||
|
readonly gpsIntervalSeconds: number;
|
||||||
|
readonly checkoutWarningRadiusMeters: number;
|
||||||
readonly status: Status;
|
readonly status: Status;
|
||||||
readonly createdAt: DateTime;
|
readonly createdAt: DateTime;
|
||||||
readonly updatedAt: DateTime;
|
readonly updatedAt: DateTime;
|
||||||
@@ -13,5 +16,8 @@ export type CompanySetting = {
|
|||||||
|
|
||||||
export type UpsertCompanySettingInput = {
|
export type UpsertCompanySettingInput = {
|
||||||
readonly cycleStartDate: DateTime;
|
readonly cycleStartDate: DateTime;
|
||||||
|
readonly checkInRadiusMeters?: number;
|
||||||
|
readonly gpsIntervalSeconds?: number;
|
||||||
|
readonly checkoutWarningRadiusMeters?: number;
|
||||||
readonly userId: string;
|
readonly userId: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -47,6 +47,6 @@ export class CompanySettingsController {
|
|||||||
@Body() dto: UpdateCompanySettingDto,
|
@Body() dto: UpdateCompanySettingDto,
|
||||||
@CurrentUser('id') userId: string,
|
@CurrentUser('id') userId: string,
|
||||||
): Promise<CompanySettingDto> {
|
): Promise<CompanySettingDto> {
|
||||||
return this.companySettingsService.update(dto.cycleStartDate, userId);
|
return this.companySettingsService.update(dto, userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ export class CompanySettingsRepository {
|
|||||||
.insert(companySettings)
|
.insert(companySettings)
|
||||||
.values({
|
.values({
|
||||||
cycleStartDate: input.cycleStartDate.value,
|
cycleStartDate: input.cycleStartDate.value,
|
||||||
|
checkInRadiusMeters: input.checkInRadiusMeters ?? 100,
|
||||||
|
gpsIntervalSeconds: input.gpsIntervalSeconds ?? 5,
|
||||||
|
checkoutWarningRadiusMeters: input.checkoutWarningRadiusMeters ?? 200,
|
||||||
status: Status.create(Status.DEFAULT).value,
|
status: Status.create(Status.DEFAULT).value,
|
||||||
createdAt: now.value,
|
createdAt: now.value,
|
||||||
updatedAt: now.value,
|
updatedAt: now.value,
|
||||||
@@ -46,6 +49,15 @@ export class CompanySettingsRepository {
|
|||||||
.update(companySettings)
|
.update(companySettings)
|
||||||
.set({
|
.set({
|
||||||
cycleStartDate: input.cycleStartDate.value,
|
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,
|
updatedAt: now.value,
|
||||||
updatedBy: input.userId,
|
updatedBy: input.userId,
|
||||||
})
|
})
|
||||||
@@ -58,6 +70,9 @@ export class CompanySettingsRepository {
|
|||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
cycleStartDate: DateTime.fromUnixMs(row.cycleStartDate),
|
cycleStartDate: DateTime.fromUnixMs(row.cycleStartDate),
|
||||||
|
checkInRadiusMeters: row.checkInRadiusMeters,
|
||||||
|
gpsIntervalSeconds: row.gpsIntervalSeconds,
|
||||||
|
checkoutWarningRadiusMeters: row.checkoutWarningRadiusMeters,
|
||||||
status: Status.create(row.status),
|
status: Status.create(row.status),
|
||||||
createdAt: DateTime.fromUnixMs(row.createdAt),
|
createdAt: DateTime.fromUnixMs(row.createdAt),
|
||||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ describe('CompanySettingsService', () => {
|
|||||||
const sample: CompanySetting = {
|
const sample: CompanySetting = {
|
||||||
id: 'set-1',
|
id: 'set-1',
|
||||||
cycleStartDate: DateTime.create('2026-01-05'),
|
cycleStartDate: DateTime.create('2026-01-05'),
|
||||||
|
checkInRadiusMeters: 100,
|
||||||
|
gpsIntervalSeconds: 5,
|
||||||
|
checkoutWarningRadiusMeters: 200,
|
||||||
status: Status.create('draft'),
|
status: Status.create('draft'),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -46,19 +49,46 @@ describe('CompanySettingsService', () => {
|
|||||||
repository.find.mockResolvedValue(sample);
|
repository.find.mockResolvedValue(sample);
|
||||||
const result = await service.get();
|
const result = await service.get();
|
||||||
expect(result.cycleStartDate).toBe(sample.cycleStartDate.value);
|
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 () => {
|
it('update persists start of day', async () => {
|
||||||
|
repository.find.mockResolvedValue(sample);
|
||||||
repository.upsert.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];
|
const arg = repository.upsert.mock.calls[0][0];
|
||||||
expect(arg.cycleStartDate.equals(DateTime.create('2026-01-05'))).toBe(true);
|
expect(arg.cycleStartDate.equals(DateTime.create('2026-01-05'))).toBe(true);
|
||||||
expect(arg.userId).toBe('user-1');
|
expect(arg.userId).toBe('user-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('update rejects invalid dates', async () => {
|
it('update rejects invalid check-in radius', async () => {
|
||||||
await expect(service.update('not-a-date', 'user-1')).rejects.toBeInstanceOf(
|
repository.find.mockResolvedValue(sample);
|
||||||
BadRequestException,
|
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(
|
async update(
|
||||||
cycleStartDateRaw: string,
|
input: {
|
||||||
|
cycleStartDate?: string;
|
||||||
|
checkInRadiusMeters?: number;
|
||||||
|
gpsIntervalSeconds?: number;
|
||||||
|
checkoutWarningRadiusMeters?: number;
|
||||||
|
},
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<ReturnType<CompanySettingsService['toItem']>> {
|
): 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({
|
const saved = await this.companySettingsRepository.upsert({
|
||||||
cycleStartDate,
|
cycleStartDate,
|
||||||
|
checkInRadiusMeters: input.checkInRadiusMeters,
|
||||||
|
gpsIntervalSeconds: input.gpsIntervalSeconds,
|
||||||
|
checkoutWarningRadiusMeters: input.checkoutWarningRadiusMeters,
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
return this.toItem(saved);
|
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> {
|
async requireCycleStartDate(): Promise<DateTime> {
|
||||||
const setting = await this.companySettingsRepository.find();
|
const setting = await this.companySettingsRepository.find();
|
||||||
if (!setting) {
|
if (!setting) {
|
||||||
@@ -42,10 +83,27 @@ export class CompanySettingsService {
|
|||||||
return setting.cycleStartDate;
|
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) {
|
toItem(setting: CompanySetting) {
|
||||||
return {
|
return {
|
||||||
id: setting.id,
|
id: setting.id,
|
||||||
cycleStartDate: setting.cycleStartDate.value,
|
cycleStartDate: setting.cycleStartDate.value,
|
||||||
|
checkInRadiusMeters: setting.checkInRadiusMeters,
|
||||||
|
gpsIntervalSeconds: setting.gpsIntervalSeconds,
|
||||||
|
checkoutWarningRadiusMeters: setting.checkoutWarningRadiusMeters,
|
||||||
status: setting.status.value,
|
status: setting.status.value,
|
||||||
createdAt: setting.createdAt.value,
|
createdAt: setting.createdAt.value,
|
||||||
updatedAt: setting.updatedAt.value,
|
updatedAt: setting.updatedAt.value,
|
||||||
|
|||||||
@@ -1,14 +1,44 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsNotEmpty, IsString, Matches } from 'class-validator';
|
import {
|
||||||
|
IsInt,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
export class UpdateCompanySettingDto {
|
export class UpdateCompanySettingDto {
|
||||||
@ApiProperty({ example: '2026-01-05', description: 'YYYY-MM-DD' })
|
@ApiPropertyOptional({ example: '2026-01-05', description: 'YYYY-MM-DD' })
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@Matches(/^\d{4}-\d{2}-\d{2}$/, {
|
@Matches(/^\d{4}-\d{2}-\d{2}$/, {
|
||||||
message: 'cycleStartDate must be a calendar date',
|
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 {
|
export class CompanySettingDto {
|
||||||
@@ -18,6 +48,15 @@ export class CompanySettingDto {
|
|||||||
@ApiProperty({ description: 'Unix ms start of the cycle-start calendar day' })
|
@ApiProperty({ description: 'Unix ms start of the cycle-start calendar day' })
|
||||||
cycleStartDate!: number;
|
cycleStartDate!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 100 })
|
||||||
|
checkInRadiusMeters!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 5 })
|
||||||
|
gpsIntervalSeconds!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 200 })
|
||||||
|
checkoutWarningRadiusMeters!: number;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
status!: string;
|
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';
|
import { FieldPrivilegeGuard } from './field-privilege.guard';
|
||||||
|
|
||||||
describe('FieldPrivilegeGuard', () => {
|
describe('FieldPrivilegeGuard', () => {
|
||||||
const checkPermission = jest.fn();
|
const checkAnyPermission = jest.fn();
|
||||||
const getAllAndOverride = jest.fn();
|
const getAllAndOverride = jest.fn();
|
||||||
const reflector = {
|
const reflector = {
|
||||||
getAllAndOverride,
|
getAllAndOverride,
|
||||||
} as unknown as Reflector;
|
} as unknown as Reflector;
|
||||||
|
|
||||||
const guard = new FieldPrivilegeGuard(reflector, {
|
const guard = new FieldPrivilegeGuard(reflector, {
|
||||||
checkPermission,
|
checkAnyPermission,
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
const user: AuthUser = {
|
const user: AuthUser = {
|
||||||
@@ -48,7 +48,7 @@ describe('FieldPrivilegeGuard', () => {
|
|||||||
it('allows when no field privilege metadata', async () => {
|
it('allows when no field privilege metadata', async () => {
|
||||||
getAllAndOverride.mockReturnValue(undefined);
|
getAllAndOverride.mockReturnValue(undefined);
|
||||||
await expect(guard.canActivate(createContext(user))).resolves.toBe(true);
|
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 () => {
|
it('allows sales purpose when sales key is granted', async () => {
|
||||||
@@ -57,8 +57,9 @@ describe('FieldPrivilegeGuard', () => {
|
|||||||
action: 'create',
|
action: 'create',
|
||||||
};
|
};
|
||||||
getAllAndOverride.mockReturnValue(meta);
|
getAllAndOverride.mockReturnValue(meta);
|
||||||
checkPermission.mockImplementation((_id: string, key: string) =>
|
checkAnyPermission.mockImplementation(
|
||||||
Promise.resolve(key === 'SALES.CYCLE'),
|
(_id: string, keys: readonly string[]) =>
|
||||||
|
Promise.resolve(keys.includes('ADMIN.SALES.DATA.CYCLE')),
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -72,8 +73,9 @@ describe('FieldPrivilegeGuard', () => {
|
|||||||
action: 'update',
|
action: 'update',
|
||||||
};
|
};
|
||||||
getAllAndOverride.mockReturnValue(meta);
|
getAllAndOverride.mockReturnValue(meta);
|
||||||
checkPermission.mockImplementation((_id: string, key: string) =>
|
checkAnyPermission.mockImplementation(
|
||||||
Promise.resolve(key === 'SALES.PLAN'),
|
(_id: string, keys: readonly string[]) =>
|
||||||
|
Promise.resolve(keys.includes('MOBILE.SALES.PLAN')),
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -87,8 +89,9 @@ describe('FieldPrivilegeGuard', () => {
|
|||||||
action: 'view',
|
action: 'view',
|
||||||
};
|
};
|
||||||
getAllAndOverride.mockReturnValue(meta);
|
getAllAndOverride.mockReturnValue(meta);
|
||||||
checkPermission.mockImplementation((_id: string, key: string) =>
|
checkAnyPermission.mockImplementation(
|
||||||
Promise.resolve(key === 'LOGISTICS.CYCLE'),
|
(_id: string, keys: readonly string[]) =>
|
||||||
|
Promise.resolve(keys.includes('ADMIN.LOGISTICS.DATA.CYCLE')),
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(guard.canActivate(createContext(user, {}, {}))).resolves.toBe(
|
await expect(guard.canActivate(createContext(user, {}, {}))).resolves.toBe(
|
||||||
@@ -106,7 +109,7 @@ describe('FieldPrivilegeGuard', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
guard.canActivate(createContext({ ...user, isSuperadmin: true })),
|
guard.canActivate(createContext({ ...user, isSuperadmin: true })),
|
||||||
).resolves.toBe(true);
|
).resolves.toBe(true);
|
||||||
expect(checkPermission).not.toHaveBeenCalled();
|
expect(checkAnyPermission).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('unauthorized when metadata present but no user', async () => {
|
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 type { PrivilegeAction } from '../../privileges/privilege-action';
|
||||||
import { PrivilegesService } from '../../privileges/privileges.service';
|
import { PrivilegesService } from '../../privileges/privileges.service';
|
||||||
import {
|
import {
|
||||||
fieldPrivilegeKey,
|
fieldPrivilegeKeys,
|
||||||
isFieldPurpose,
|
isFieldPurpose,
|
||||||
type FieldPurpose,
|
type FieldPurpose,
|
||||||
type FieldResource,
|
type FieldResource,
|
||||||
@@ -78,9 +78,9 @@ export class FieldPrivilegeGuard implements CanActivate {
|
|||||||
const purposes: FieldPurpose[] = ['sales', 'logistics'];
|
const purposes: FieldPurpose[] = ['sales', 'logistics'];
|
||||||
const matches: FieldPurpose[] = [];
|
const matches: FieldPurpose[] = [];
|
||||||
for (const purpose of purposes) {
|
for (const purpose of purposes) {
|
||||||
const ok = await this.privilegesService.checkPermission(
|
const ok = await this.privilegesService.checkAnyPermission(
|
||||||
userId,
|
userId,
|
||||||
fieldPrivilegeKey(resource, purpose),
|
fieldPrivilegeKeys(resource, purpose),
|
||||||
action,
|
action,
|
||||||
);
|
);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
cycleNumberForDate,
|
cycleNumberForDate,
|
||||||
fieldPrivilegeKey,
|
fieldPrivilegeKeys,
|
||||||
isFieldPurpose,
|
isFieldPurpose,
|
||||||
isWeekdayName,
|
isWeekdayName,
|
||||||
noCycleMessage,
|
noCycleMessage,
|
||||||
@@ -19,8 +19,13 @@ describe('field purpose helpers', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('maps resource and purpose to privilege keys', () => {
|
it('maps resource and purpose to privilege keys', () => {
|
||||||
expect(fieldPrivilegeKey('cycle', 'sales')).toBe('SALES.CYCLE');
|
expect(fieldPrivilegeKeys('cycle', 'sales')).toEqual([
|
||||||
expect(fieldPrivilegeKey('plan', 'logistics')).toBe('LOGISTICS.PLAN');
|
'ADMIN.SALES.DATA.CYCLE',
|
||||||
|
]);
|
||||||
|
expect(fieldPrivilegeKeys('plan', 'logistics')).toEqual([
|
||||||
|
'ADMIN.LOGISTICS.ACTIVITIES.PLAN',
|
||||||
|
'MOBILE.LOGISTICS.PLAN',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns purpose-specific missing-cycle messages', () => {
|
it('returns purpose-specific missing-cycle messages', () => {
|
||||||
|
|||||||
@@ -22,26 +22,34 @@ export function isWeekdayName(raw: string): raw is WeekdayName {
|
|||||||
return (WEEKDAY_NAMES as readonly string[]).includes(raw);
|
return (WEEKDAY_NAMES as readonly string[]).includes(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SALES_CYCLE_PRIVILEGE_KEY = 'SALES.CYCLE';
|
export const ADMIN_SALES_CYCLE_PRIVILEGE_KEY = 'ADMIN.SALES.DATA.CYCLE';
|
||||||
export const SALES_PLAN_PRIVILEGE_KEY = 'SALES.PLAN';
|
export const ADMIN_SALES_PLAN_PRIVILEGE_KEY = 'ADMIN.SALES.ACTIVITIES.PLAN';
|
||||||
export const LOGISTICS_CYCLE_PRIVILEGE_KEY = 'LOGISTICS.CYCLE';
|
export const ADMIN_LOGISTICS_CYCLE_PRIVILEGE_KEY = 'ADMIN.LOGISTICS.DATA.CYCLE';
|
||||||
export const LOGISTICS_PLAN_PRIVILEGE_KEY = 'LOGISTICS.PLAN';
|
export const ADMIN_LOGISTICS_PLAN_PRIVILEGE_KEY =
|
||||||
export const SETTINGS_PRIVILEGE_KEY = 'CONFIGURATION.SETTING';
|
'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 type FieldResource = 'cycle' | 'plan';
|
||||||
|
|
||||||
export function fieldPrivilegeKey(
|
export function fieldPrivilegeKeys(
|
||||||
resource: FieldResource,
|
resource: FieldResource,
|
||||||
purpose: FieldPurpose,
|
purpose: FieldPurpose,
|
||||||
): string {
|
): readonly string[] {
|
||||||
if (resource === 'cycle') {
|
if (resource === 'cycle') {
|
||||||
return purpose === 'sales'
|
return purpose === 'sales'
|
||||||
? SALES_CYCLE_PRIVILEGE_KEY
|
? [ADMIN_SALES_CYCLE_PRIVILEGE_KEY]
|
||||||
: LOGISTICS_CYCLE_PRIVILEGE_KEY;
|
: [ADMIN_LOGISTICS_CYCLE_PRIVILEGE_KEY];
|
||||||
}
|
}
|
||||||
return purpose === 'sales'
|
return purpose === 'sales'
|
||||||
? SALES_PLAN_PRIVILEGE_KEY
|
? [ADMIN_SALES_PLAN_PRIVILEGE_KEY, MOBILE_SALES_PLAN_PRIVILEGE_KEY]
|
||||||
: LOGISTICS_PLAN_PRIVILEGE_KEY;
|
: [ADMIN_LOGISTICS_PLAN_PRIVILEGE_KEY, MOBILE_LOGISTICS_PLAN_PRIVILEGE_KEY];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function noCycleMessage(purpose: FieldPurpose): string {
|
export function noCycleMessage(purpose: FieldPurpose): string {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Haversine distance between two WGS-84 coordinates in meters.
|
||||||
|
*/
|
||||||
|
export function haversineDistanceMeters(
|
||||||
|
lat1: number,
|
||||||
|
lon1: number,
|
||||||
|
lat2: number,
|
||||||
|
lon2: number,
|
||||||
|
): number {
|
||||||
|
const earthRadiusMeters = 6_371_000;
|
||||||
|
const toRadians = (degrees: number) => (degrees * Math.PI) / 180;
|
||||||
|
const dLat = toRadians(lat2 - lat1);
|
||||||
|
const dLon = toRadians(lon2 - lon1);
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(toRadians(lat1)) *
|
||||||
|
Math.cos(toRadians(lat2)) *
|
||||||
|
Math.sin(dLon / 2) ** 2;
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
return earthRadiusMeters * c;
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
ArrayNotEmpty,
|
||||||
|
IsArray,
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Matches,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
import {
|
||||||
|
DefaultRelationDto,
|
||||||
|
PaginationMetaDto,
|
||||||
|
} from '../../../../common/http/response';
|
||||||
|
import { TIMELINE_ACTIVITY_TYPES } from '../../../../database/timeline-activities-table';
|
||||||
|
|
||||||
|
export class TimelineConfigDto {
|
||||||
|
@ApiProperty({ example: 5 })
|
||||||
|
gpsIntervalSeconds!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 200 })
|
||||||
|
checkoutWarningRadiusMeters!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TimelineFootprintPointDto {
|
||||||
|
@ApiProperty({ example: -6.2 })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(-90)
|
||||||
|
@Max(90)
|
||||||
|
latitude!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 106.8 })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(-180)
|
||||||
|
@Max(180)
|
||||||
|
longitude!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Unix ms when the point was recorded' })
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
recordedAt!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IngestTimelineFootprintsDto {
|
||||||
|
@ApiProperty({ type: [TimelineFootprintPointDto] })
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@ArrayMaxSize(100)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => TimelineFootprintPointDto)
|
||||||
|
points!: TimelineFootprintPointDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IngestTimelineFootprintsResultDto {
|
||||||
|
@ApiProperty()
|
||||||
|
inserted!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListTimelineQueryDto {
|
||||||
|
@ApiPropertyOptional({ example: '2026-09-01', description: 'YYYY-MM-DD' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'date must be YYYY-MM-DD' })
|
||||||
|
date?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
employeeId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TimelineFootprintDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: DefaultRelationDto })
|
||||||
|
employee!: DefaultRelationDto;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
latitude!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
longitude!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
recordedAt!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TimelineActivityDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: DefaultRelationDto })
|
||||||
|
employee!: DefaultRelationDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||||
|
customer!: DefaultRelationDto | null;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'uuid', nullable: true })
|
||||||
|
visitId!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: TIMELINE_ACTIVITY_TYPES })
|
||||||
|
type!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
sourceType!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
sourceId!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
latitude!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
longitude!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
recordedAt!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TimelineDayDto {
|
||||||
|
@ApiProperty()
|
||||||
|
date!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [TimelineFootprintDto] })
|
||||||
|
footprints!: TimelineFootprintDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: [TimelineActivityDto] })
|
||||||
|
activities!: TimelineActivityDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TimelineMeDto {
|
||||||
|
@ApiProperty()
|
||||||
|
date!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [TimelineActivityDto] })
|
||||||
|
activities!: TimelineActivityDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
void PaginationMetaDto;
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { and, eq, gte, isNull, lte, sql } from 'drizzle-orm';
|
||||||
|
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||||
|
import { customers } from '../../../database/customers-table';
|
||||||
|
import {
|
||||||
|
timelineActivities,
|
||||||
|
type TimelineActivityRow,
|
||||||
|
type TimelineActivityType,
|
||||||
|
} from '../../../database/timeline-activities-table';
|
||||||
|
import { visits } from '../../../database/visits-table';
|
||||||
|
import { employees } from '../../../database/schema';
|
||||||
|
import type {
|
||||||
|
ListTimelineFilters,
|
||||||
|
RecordTimelineActivityInput,
|
||||||
|
TimelineActivity,
|
||||||
|
} from './timeline.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TimelineActivitiesRepository {
|
||||||
|
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||||
|
|
||||||
|
async insert(input: RecordTimelineActivityInput): Promise<TimelineActivity> {
|
||||||
|
const recordedAt = input.recordedAt ?? Date.now();
|
||||||
|
const inserted = await this.db
|
||||||
|
.insert(timelineActivities)
|
||||||
|
.values({
|
||||||
|
employeeId: input.employeeId,
|
||||||
|
customerId: input.customerId ?? null,
|
||||||
|
visitId: input.visitId ?? null,
|
||||||
|
type: input.type,
|
||||||
|
sourceType: input.sourceType,
|
||||||
|
sourceId: input.sourceId,
|
||||||
|
latitude: input.latitude,
|
||||||
|
longitude: input.longitude,
|
||||||
|
recordedAt,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
const row = inserted[0];
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
employeeId: row.employeeId,
|
||||||
|
customerId: row.customerId,
|
||||||
|
visitId: row.visitId,
|
||||||
|
type: row.type as TimelineActivityType,
|
||||||
|
sourceType: row.sourceType,
|
||||||
|
sourceId: row.sourceId,
|
||||||
|
latitude: row.latitude,
|
||||||
|
longitude: row.longitude,
|
||||||
|
recordedAt: row.recordedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOpenVisitForEmployee(
|
||||||
|
employeeId: string,
|
||||||
|
): Promise<{ visitId: string; customerId: string } | null> {
|
||||||
|
const rows = await this.db
|
||||||
|
.select({
|
||||||
|
visitId: visits.id,
|
||||||
|
customerId: visits.customerId,
|
||||||
|
})
|
||||||
|
.from(visits)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(visits.employeeId, employeeId),
|
||||||
|
isNull(visits.checkOutAt),
|
||||||
|
sql`${visits.status} <> 'archived'`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
const row = rows[0];
|
||||||
|
return row ? { visitId: row.visitId, customerId: row.customerId } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(filters: ListTimelineFilters): Promise<TimelineActivity[]> {
|
||||||
|
const conditions = [
|
||||||
|
gte(timelineActivities.recordedAt, filters.dayStartMs),
|
||||||
|
lte(timelineActivities.recordedAt, filters.dayEndMs),
|
||||||
|
];
|
||||||
|
if (filters.employeeId) {
|
||||||
|
conditions.push(eq(timelineActivities.employeeId, filters.employeeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.db
|
||||||
|
.select({
|
||||||
|
activity: timelineActivities,
|
||||||
|
employee: employees,
|
||||||
|
customer: customers,
|
||||||
|
})
|
||||||
|
.from(timelineActivities)
|
||||||
|
.innerJoin(employees, eq(timelineActivities.employeeId, employees.id))
|
||||||
|
.leftJoin(customers, eq(timelineActivities.customerId, customers.id))
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(timelineActivities.recordedAt);
|
||||||
|
|
||||||
|
return rows.map((row) =>
|
||||||
|
this.toDomain(row.activity, row.employee, row.customer),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDomain(
|
||||||
|
row: TimelineActivityRow,
|
||||||
|
employee?: typeof employees.$inferSelect,
|
||||||
|
customer?: typeof customers.$inferSelect | null,
|
||||||
|
): TimelineActivity {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
employeeId: row.employeeId,
|
||||||
|
customerId: row.customerId,
|
||||||
|
visitId: row.visitId,
|
||||||
|
type: row.type as TimelineActivityType,
|
||||||
|
sourceType: row.sourceType,
|
||||||
|
sourceId: row.sourceId,
|
||||||
|
latitude: row.latitude,
|
||||||
|
longitude: row.longitude,
|
||||||
|
recordedAt: row.recordedAt,
|
||||||
|
...(employee
|
||||||
|
? {
|
||||||
|
employee: {
|
||||||
|
id: employee.id,
|
||||||
|
code: employee.code,
|
||||||
|
name: employee.name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
customer: customer
|
||||||
|
? { id: customer.id, code: customer.code, name: customer.name }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import type { TimelineActivityType } from '../../../database/timeline-activities-table';
|
||||||
|
import { TimelineActivitiesRepository } from './timeline-activities.repository';
|
||||||
|
import type { RecordTimelineActivityInput } from './timeline.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TimelineActivitiesService {
|
||||||
|
constructor(
|
||||||
|
private readonly timelineActivitiesRepository: TimelineActivitiesRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async record(input: RecordTimelineActivityInput): Promise<void> {
|
||||||
|
const openVisit =
|
||||||
|
input.visitId === undefined && input.customerId === undefined
|
||||||
|
? await this.timelineActivitiesRepository.findOpenVisitForEmployee(
|
||||||
|
input.employeeId,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
await this.timelineActivitiesRepository.insert({
|
||||||
|
...input,
|
||||||
|
visitId: input.visitId ?? openVisit?.visitId ?? null,
|
||||||
|
customerId:
|
||||||
|
input.customerId ??
|
||||||
|
openVisit?.customerId ??
|
||||||
|
(input.type === 'customer_created' ? input.sourceId : null),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordIfLocated(input: {
|
||||||
|
readonly employeeId: string;
|
||||||
|
readonly type: TimelineActivityType;
|
||||||
|
readonly sourceType: string;
|
||||||
|
readonly sourceId: string;
|
||||||
|
readonly latitude?: number | null;
|
||||||
|
readonly longitude?: number | null;
|
||||||
|
readonly recordedAt?: number;
|
||||||
|
readonly customerId?: string | null;
|
||||||
|
readonly visitId?: string | null;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (
|
||||||
|
input.latitude === undefined ||
|
||||||
|
input.latitude === null ||
|
||||||
|
input.longitude === undefined ||
|
||||||
|
input.longitude === null
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.record({
|
||||||
|
employeeId: input.employeeId,
|
||||||
|
type: input.type,
|
||||||
|
sourceType: input.sourceType,
|
||||||
|
sourceId: input.sourceId,
|
||||||
|
latitude: input.latitude,
|
||||||
|
longitude: input.longitude,
|
||||||
|
recordedAt: input.recordedAt,
|
||||||
|
customerId: input.customerId,
|
||||||
|
visitId: input.visitId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { and, eq, gte, lte } from 'drizzle-orm';
|
||||||
|
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||||
|
import {
|
||||||
|
timelineFootprints,
|
||||||
|
type TimelineFootprintRow,
|
||||||
|
} from '../../../database/timeline-footprints-table';
|
||||||
|
import { employees } from '../../../database/schema';
|
||||||
|
import type {
|
||||||
|
IngestFootprintPoint,
|
||||||
|
ListTimelineFilters,
|
||||||
|
TimelineFootprint,
|
||||||
|
} from './timeline.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TimelineFootprintsRepository {
|
||||||
|
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||||
|
|
||||||
|
async insertMany(
|
||||||
|
employeeId: string,
|
||||||
|
points: readonly IngestFootprintPoint[],
|
||||||
|
): Promise<number> {
|
||||||
|
if (points.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const rows = points.map((point) => ({
|
||||||
|
employeeId,
|
||||||
|
latitude: point.latitude,
|
||||||
|
longitude: point.longitude,
|
||||||
|
recordedAt: point.recordedAt,
|
||||||
|
}));
|
||||||
|
const inserted = await this.db
|
||||||
|
.insert(timelineFootprints)
|
||||||
|
.values(rows)
|
||||||
|
.returning({ id: timelineFootprints.id });
|
||||||
|
return inserted.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(filters: ListTimelineFilters): Promise<TimelineFootprint[]> {
|
||||||
|
const conditions = [
|
||||||
|
gte(timelineFootprints.recordedAt, filters.dayStartMs),
|
||||||
|
lte(timelineFootprints.recordedAt, filters.dayEndMs),
|
||||||
|
];
|
||||||
|
if (filters.employeeId) {
|
||||||
|
conditions.push(eq(timelineFootprints.employeeId, filters.employeeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.db
|
||||||
|
.select({
|
||||||
|
footprint: timelineFootprints,
|
||||||
|
employee: employees,
|
||||||
|
})
|
||||||
|
.from(timelineFootprints)
|
||||||
|
.innerJoin(employees, eq(timelineFootprints.employeeId, employees.id))
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(timelineFootprints.recordedAt);
|
||||||
|
|
||||||
|
return rows.map((row) => this.toDomain(row.footprint, row.employee));
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDomain(
|
||||||
|
row: TimelineFootprintRow,
|
||||||
|
employee?: typeof employees.$inferSelect,
|
||||||
|
): TimelineFootprint {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
employeeId: row.employeeId,
|
||||||
|
latitude: row.latitude,
|
||||||
|
longitude: row.longitude,
|
||||||
|
recordedAt: row.recordedAt,
|
||||||
|
...(employee
|
||||||
|
? {
|
||||||
|
employee: {
|
||||||
|
id: employee.id,
|
||||||
|
code: employee.code,
|
||||||
|
name: employee.name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiForbiddenResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
ApiUnauthorizedResponse,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||||
|
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
||||||
|
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||||
|
import {
|
||||||
|
ADMIN_SALES_TIMELINE_PRIVILEGE_KEY,
|
||||||
|
FIELD_ATTENDANCE_PRIVILEGE_KEY,
|
||||||
|
MOBILE_SALES_TIMELINE_PRIVILEGE_KEY,
|
||||||
|
} from '../shared/field-purpose';
|
||||||
|
import {
|
||||||
|
ListTimelineQueryDto,
|
||||||
|
TimelineConfigDto,
|
||||||
|
TimelineDayDto,
|
||||||
|
TimelineMeDto,
|
||||||
|
} from './dto/timeline.dto';
|
||||||
|
import { TimelineService } from './timeline.service';
|
||||||
|
|
||||||
|
@ApiTags('timeline')
|
||||||
|
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||||
|
@Controller('timeline')
|
||||||
|
export class TimelineReadController {
|
||||||
|
constructor(private readonly timelineService: TimelineService) {}
|
||||||
|
|
||||||
|
@Get('config')
|
||||||
|
@RequirePrivilege(
|
||||||
|
[MOBILE_SALES_TIMELINE_PRIVILEGE_KEY, FIELD_ATTENDANCE_PRIVILEGE_KEY],
|
||||||
|
'view',
|
||||||
|
)
|
||||||
|
@ApiOperation({ summary: 'Get timeline tracking configuration' })
|
||||||
|
@ApiOkResponse({ type: TimelineConfigDto })
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
config(): Promise<TimelineConfigDto> {
|
||||||
|
return this.timelineService.getConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('me')
|
||||||
|
@RequirePrivilege(MOBILE_SALES_TIMELINE_PRIVILEGE_KEY, 'view')
|
||||||
|
@ApiOperation({ summary: 'Get current user timeline activities for a day' })
|
||||||
|
@ApiOkResponse({ type: TimelineMeDto })
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
me(
|
||||||
|
@Query() query: ListTimelineQueryDto,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
): Promise<TimelineMeDto> {
|
||||||
|
return this.timelineService.getMyDay(query, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePrivilege(ADMIN_SALES_TIMELINE_PRIVILEGE_KEY, 'view')
|
||||||
|
@ApiOperation({ summary: 'Get timeline footprints and activities for a day' })
|
||||||
|
@ApiOkResponse({ type: TimelineDayDto })
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
day(@Query() query: ListTimelineQueryDto): Promise<TimelineDayDto> {
|
||||||
|
return this.timelineService.getDay(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Body, Controller, Post } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiCreatedResponse,
|
||||||
|
ApiForbiddenResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
ApiUnauthorizedResponse,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||||
|
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
||||||
|
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||||
|
import { MOBILE_SALES_TIMELINE_PRIVILEGE_KEY } from '../shared/field-purpose';
|
||||||
|
import {
|
||||||
|
IngestTimelineFootprintsDto,
|
||||||
|
IngestTimelineFootprintsResultDto,
|
||||||
|
} from './dto/timeline.dto';
|
||||||
|
import { TimelineService } from './timeline.service';
|
||||||
|
|
||||||
|
@ApiTags('timeline')
|
||||||
|
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||||
|
@Controller('timeline')
|
||||||
|
export class TimelineWriteController {
|
||||||
|
constructor(private readonly timelineService: TimelineService) {}
|
||||||
|
|
||||||
|
@Post('footprints')
|
||||||
|
@RequirePrivilege(MOBILE_SALES_TIMELINE_PRIVILEGE_KEY, 'create')
|
||||||
|
@ApiOperation({ summary: 'Ingest GPS footprint points' })
|
||||||
|
@ApiCreatedResponse({ type: IngestTimelineFootprintsResultDto })
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
ingestFootprints(
|
||||||
|
@Body() dto: IngestTimelineFootprintsDto,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
): Promise<IngestTimelineFootprintsResultDto> {
|
||||||
|
return this.timelineService.ingestFootprints(dto, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { EmployeesModule } from '../../configuration/employees/employees.module';
|
||||||
|
import { CompanySettingsRepository } from '../settings/company-settings.repository';
|
||||||
|
import { CompanySettingsService } from '../settings/company-settings.service';
|
||||||
|
import { TimelineActivitiesRepository } from './timeline-activities.repository';
|
||||||
|
import { TimelineActivitiesService } from './timeline-activities.service';
|
||||||
|
import { TimelineFootprintsRepository } from './timeline-footprints.repository';
|
||||||
|
import { TimelineReadController } from './timeline-read.controller';
|
||||||
|
import { TimelineWriteController } from './timeline-write.controller';
|
||||||
|
import { TimelineService } from './timeline.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [EmployeesModule],
|
||||||
|
controllers: [TimelineReadController, TimelineWriteController],
|
||||||
|
providers: [
|
||||||
|
CompanySettingsRepository,
|
||||||
|
CompanySettingsService,
|
||||||
|
TimelineFootprintsRepository,
|
||||||
|
TimelineActivitiesRepository,
|
||||||
|
TimelineActivitiesService,
|
||||||
|
TimelineService,
|
||||||
|
],
|
||||||
|
exports: [TimelineActivitiesService, TimelineService],
|
||||||
|
})
|
||||||
|
export class TimelineModule {}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
|
import { EmployeesService } from '../../configuration/employees/employees.service';
|
||||||
|
import type { CompanySetting } from '../settings/company-setting';
|
||||||
|
import { CompanySettingsService } from '../settings/company-settings.service';
|
||||||
|
import { TimelineActivitiesRepository } from './timeline-activities.repository';
|
||||||
|
import { TimelineFootprintsRepository } from './timeline-footprints.repository';
|
||||||
|
import { TimelineService } from './timeline.service';
|
||||||
|
|
||||||
|
describe('TimelineService', () => {
|
||||||
|
let service: TimelineService;
|
||||||
|
let footprintsRepository: jest.Mocked<
|
||||||
|
Pick<TimelineFootprintsRepository, 'insertMany' | 'list'>
|
||||||
|
>;
|
||||||
|
let activitiesRepository: jest.Mocked<Pick<TimelineActivitiesRepository, 'list'>>;
|
||||||
|
let employeesService: jest.Mocked<Pick<EmployeesService, 'requireByUserId'>>;
|
||||||
|
let companySettingsService: jest.Mocked<
|
||||||
|
Pick<CompanySettingsService, 'requireTimelineConfig'>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const nowMs = DateTime.fromUnixMs(Date.now()).startOfDay().value + 3_600_000;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
footprintsRepository = {
|
||||||
|
insertMany: jest.fn(),
|
||||||
|
list: jest.fn(),
|
||||||
|
};
|
||||||
|
activitiesRepository = {
|
||||||
|
list: jest.fn(),
|
||||||
|
};
|
||||||
|
employeesService = {
|
||||||
|
requireByUserId: jest.fn(),
|
||||||
|
};
|
||||||
|
companySettingsService = {
|
||||||
|
requireTimelineConfig: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
TimelineService,
|
||||||
|
{ provide: TimelineFootprintsRepository, useValue: footprintsRepository },
|
||||||
|
{ provide: TimelineActivitiesRepository, useValue: activitiesRepository },
|
||||||
|
{ provide: EmployeesService, useValue: employeesService },
|
||||||
|
{ provide: CompanySettingsService, useValue: companySettingsService },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = moduleRef.get(TimelineService);
|
||||||
|
companySettingsService.requireTimelineConfig.mockResolvedValue({
|
||||||
|
gpsIntervalSeconds: 5,
|
||||||
|
checkoutWarningRadiusMeters: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns timeline config from company settings', async () => {
|
||||||
|
await expect(service.getConfig()).resolves.toEqual({
|
||||||
|
gpsIntervalSeconds: 5,
|
||||||
|
checkoutWarningRadiusMeters: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ingests footprint points for the current employee', async () => {
|
||||||
|
employeesService.requireByUserId.mockResolvedValue({
|
||||||
|
id: 'emp-1',
|
||||||
|
} as Awaited<ReturnType<EmployeesService['requireByUserId']>>);
|
||||||
|
footprintsRepository.insertMany.mockResolvedValue(1);
|
||||||
|
|
||||||
|
const result = await service.ingestFootprints(
|
||||||
|
{
|
||||||
|
points: [{ latitude: -6.2, longitude: 106.8, recordedAt: nowMs }],
|
||||||
|
},
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ inserted: 1 });
|
||||||
|
expect(footprintsRepository.insertMany).toHaveBeenCalledWith('emp-1', [
|
||||||
|
{ latitude: -6.2, longitude: 106.8, recordedAt: nowMs },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects stale footprint timestamps', async () => {
|
||||||
|
employeesService.requireByUserId.mockResolvedValue({
|
||||||
|
id: 'emp-1',
|
||||||
|
} as Awaited<ReturnType<EmployeesService['requireByUserId']>>);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.ingestFootprints(
|
||||||
|
{
|
||||||
|
points: [
|
||||||
|
{
|
||||||
|
latitude: -6.2,
|
||||||
|
longitude: 106.8,
|
||||||
|
recordedAt: Date.now() - 25 * 60 * 60 * 1000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'user-1',
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('downsamples footprints when querying all employees', async () => {
|
||||||
|
const footprints = Array.from({ length: 4000 }, (_, index) => ({
|
||||||
|
id: `fp-${index}`,
|
||||||
|
employeeId: 'emp-1',
|
||||||
|
latitude: -6.2,
|
||||||
|
longitude: 106.8 + index * 0.0001,
|
||||||
|
recordedAt: nowMs + index * 1000,
|
||||||
|
employee: { id: 'emp-1', code: 'E1', name: 'Ada' },
|
||||||
|
}));
|
||||||
|
footprintsRepository.list.mockResolvedValue(footprints);
|
||||||
|
activitiesRepository.list.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await service.getDay({ date: '2026-09-01' });
|
||||||
|
|
||||||
|
expect(result.footprints.length).toBeLessThanOrEqual(2000);
|
||||||
|
expect(result.activities).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
DEFAULT_RELATION_FIELDS,
|
||||||
|
pickRelation,
|
||||||
|
} from '../../../common/http/response';
|
||||||
|
import { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error';
|
||||||
|
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
|
import { EmployeesService } from '../../configuration/employees/employees.service';
|
||||||
|
import { CompanySettingsService } from '../settings/company-settings.service';
|
||||||
|
import type {
|
||||||
|
IngestTimelineFootprintsDto,
|
||||||
|
ListTimelineQueryDto,
|
||||||
|
TimelineActivityDto,
|
||||||
|
TimelineConfigDto,
|
||||||
|
TimelineDayDto,
|
||||||
|
TimelineFootprintDto,
|
||||||
|
TimelineMeDto,
|
||||||
|
} from './dto/timeline.dto';
|
||||||
|
import { TimelineActivitiesRepository } from './timeline-activities.repository';
|
||||||
|
import { TimelineFootprintsRepository } from './timeline-footprints.repository';
|
||||||
|
import type { TimelineActivity, TimelineFootprint } from './timeline.types';
|
||||||
|
|
||||||
|
const MS_PER_DAY = 86_400_000;
|
||||||
|
const MAX_FOOTPRINTS_ALL_EMPLOYEES = 2000;
|
||||||
|
const MAX_FOOTPRINT_AGE_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TimelineService {
|
||||||
|
constructor(
|
||||||
|
private readonly timelineFootprintsRepository: TimelineFootprintsRepository,
|
||||||
|
private readonly timelineActivitiesRepository: TimelineActivitiesRepository,
|
||||||
|
private readonly employeesService: EmployeesService,
|
||||||
|
private readonly companySettingsService: CompanySettingsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getConfig(): Promise<TimelineConfigDto> {
|
||||||
|
return this.companySettingsService.requireTimelineConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
async ingestFootprints(
|
||||||
|
dto: IngestTimelineFootprintsDto,
|
||||||
|
userId: string,
|
||||||
|
): Promise<{ inserted: number }> {
|
||||||
|
const employee = await this.employeesService.requireByUserId(userId);
|
||||||
|
const now = Date.now();
|
||||||
|
const points = dto.points.map((point) => {
|
||||||
|
this.assertCoordinates(point.latitude, point.longitude);
|
||||||
|
if (now - point.recordedAt > MAX_FOOTPRINT_AGE_MS) {
|
||||||
|
throw new BadRequestException('Footprint timestamp is too old');
|
||||||
|
}
|
||||||
|
if (point.recordedAt > now + 60_000) {
|
||||||
|
throw new BadRequestException('Footprint timestamp is in the future');
|
||||||
|
}
|
||||||
|
return point;
|
||||||
|
});
|
||||||
|
|
||||||
|
const inserted = await this.timelineFootprintsRepository.insertMany(
|
||||||
|
employee.id,
|
||||||
|
points,
|
||||||
|
);
|
||||||
|
return { inserted };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDay(query: ListTimelineQueryDto): Promise<TimelineDayDto> {
|
||||||
|
const { dateLabel, filters } = this.resolveDayFilters(query);
|
||||||
|
const [footprints, activities] = await Promise.all([
|
||||||
|
this.timelineFootprintsRepository.list(filters),
|
||||||
|
this.timelineActivitiesRepository.list(filters),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: dateLabel,
|
||||||
|
footprints: this.downsampleFootprints(footprints, query.employeeId).map(
|
||||||
|
(item) => this.toFootprintDto(item),
|
||||||
|
),
|
||||||
|
activities: activities.map((item) => this.toActivityDto(item)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMyDay(
|
||||||
|
query: ListTimelineQueryDto,
|
||||||
|
userId: string,
|
||||||
|
): Promise<TimelineMeDto> {
|
||||||
|
const employee = await this.employeesService.requireByUserId(userId);
|
||||||
|
const { dateLabel, filters } = this.resolveDayFilters({
|
||||||
|
...query,
|
||||||
|
employeeId: employee.id,
|
||||||
|
});
|
||||||
|
const activities = await this.timelineActivitiesRepository.list(filters);
|
||||||
|
return {
|
||||||
|
date: dateLabel,
|
||||||
|
activities: activities.map((item) => this.toActivityDto(item)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveDayFilters(query: ListTimelineQueryDto): {
|
||||||
|
dateLabel: string;
|
||||||
|
filters: {
|
||||||
|
dayStartMs: number;
|
||||||
|
dayEndMs: number;
|
||||||
|
employeeId?: string;
|
||||||
|
};
|
||||||
|
} {
|
||||||
|
const dateLabel = this.resolveDateLabel(query.date);
|
||||||
|
const dayStart = DateTime.create(dateLabel).startOfDay();
|
||||||
|
return {
|
||||||
|
dateLabel,
|
||||||
|
filters: {
|
||||||
|
dayStartMs: dayStart.value,
|
||||||
|
dayEndMs: dayStart.value + MS_PER_DAY - 1,
|
||||||
|
employeeId: query.employeeId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveDateLabel(raw?: string): string {
|
||||||
|
if (!raw) {
|
||||||
|
return DateTime.fromUnixMs(Date.now()).startOfDay().format().slice(0, 10);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
DateTime.create(raw);
|
||||||
|
return raw;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof InvalidDateTimeError) {
|
||||||
|
throw new BadRequestException('Invalid date');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private downsampleFootprints(
|
||||||
|
footprints: readonly TimelineFootprint[],
|
||||||
|
employeeId?: string,
|
||||||
|
): TimelineFootprint[] {
|
||||||
|
if (employeeId || footprints.length <= MAX_FOOTPRINTS_ALL_EMPLOYEES) {
|
||||||
|
return [...footprints];
|
||||||
|
}
|
||||||
|
const stride = Math.ceil(
|
||||||
|
footprints.length / MAX_FOOTPRINTS_ALL_EMPLOYEES,
|
||||||
|
);
|
||||||
|
return footprints.filter((_, index) => index % stride === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertCoordinates(latitude: number, longitude: number): void {
|
||||||
|
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
|
||||||
|
throw new BadRequestException('Invalid coordinates');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toFootprintDto(footprint: TimelineFootprint): TimelineFootprintDto {
|
||||||
|
return {
|
||||||
|
id: footprint.id,
|
||||||
|
employee: pickRelation(footprint.employee, DEFAULT_RELATION_FIELDS)!,
|
||||||
|
latitude: footprint.latitude,
|
||||||
|
longitude: footprint.longitude,
|
||||||
|
recordedAt: footprint.recordedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toActivityDto(activity: TimelineActivity): TimelineActivityDto {
|
||||||
|
return {
|
||||||
|
id: activity.id,
|
||||||
|
employee: pickRelation(activity.employee, DEFAULT_RELATION_FIELDS)!,
|
||||||
|
customer: activity.customer
|
||||||
|
? pickRelation(activity.customer, DEFAULT_RELATION_FIELDS)
|
||||||
|
: null,
|
||||||
|
visitId: activity.visitId,
|
||||||
|
type: activity.type,
|
||||||
|
sourceType: activity.sourceType,
|
||||||
|
sourceId: activity.sourceId,
|
||||||
|
latitude: activity.latitude,
|
||||||
|
longitude: activity.longitude,
|
||||||
|
recordedAt: activity.recordedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { TimelineActivityType } from '../../../database/timeline-activities-table';
|
||||||
|
|
||||||
|
export type TimelineFootprint = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly employeeId: string;
|
||||||
|
readonly latitude: number;
|
||||||
|
readonly longitude: number;
|
||||||
|
readonly recordedAt: number;
|
||||||
|
readonly employee?: {
|
||||||
|
readonly id: string;
|
||||||
|
readonly code: string;
|
||||||
|
readonly name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TimelineActivity = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly employeeId: string;
|
||||||
|
readonly customerId: string | null;
|
||||||
|
readonly visitId: string | null;
|
||||||
|
readonly type: TimelineActivityType;
|
||||||
|
readonly sourceType: string;
|
||||||
|
readonly sourceId: string;
|
||||||
|
readonly latitude: number;
|
||||||
|
readonly longitude: number;
|
||||||
|
readonly recordedAt: number;
|
||||||
|
readonly employee?: {
|
||||||
|
readonly id: string;
|
||||||
|
readonly code: string;
|
||||||
|
readonly name: string;
|
||||||
|
};
|
||||||
|
readonly customer?: {
|
||||||
|
readonly id: string;
|
||||||
|
readonly code: string;
|
||||||
|
readonly name: string;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecordTimelineActivityInput = {
|
||||||
|
readonly employeeId: string;
|
||||||
|
readonly type: TimelineActivityType;
|
||||||
|
readonly sourceType: string;
|
||||||
|
readonly sourceId: string;
|
||||||
|
readonly latitude: number;
|
||||||
|
readonly longitude: number;
|
||||||
|
readonly recordedAt?: number;
|
||||||
|
readonly customerId?: string | null;
|
||||||
|
readonly visitId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IngestFootprintPoint = {
|
||||||
|
readonly latitude: number;
|
||||||
|
readonly longitude: number;
|
||||||
|
readonly recordedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListTimelineFilters = {
|
||||||
|
readonly dayStartMs: number;
|
||||||
|
readonly dayEndMs: number;
|
||||||
|
readonly employeeId?: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
ArrayNotEmpty,
|
||||||
|
IsArray,
|
||||||
|
IsIn,
|
||||||
|
IsLatitude,
|
||||||
|
IsLongitude,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUrl,
|
||||||
|
IsUUID,
|
||||||
|
ValidateIf,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { PaginationQueryDto } from '../../../../common/http/response';
|
||||||
|
import { CORE_STATUSES } from '../../../../common/value-objects/status/status';
|
||||||
|
import { CHECK_IN_METHODS } from '../../shared/check-in-verification';
|
||||||
|
|
||||||
|
export class VisitCheckInDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
@IsUUID('4')
|
||||||
|
customerId!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
planId?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: CHECK_IN_METHODS })
|
||||||
|
@IsIn([...CHECK_IN_METHODS])
|
||||||
|
method!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@ValidateIf((dto: VisitCheckInDto) => dto.method === 'nfc')
|
||||||
|
@IsString()
|
||||||
|
nfcId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@ValidateIf((dto: VisitCheckInDto) => dto.method === 'qr')
|
||||||
|
@IsString()
|
||||||
|
qrCode?: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsLatitude()
|
||||||
|
latitude!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsLongitude()
|
||||||
|
longitude!: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl({ require_protocol: true, protocols: ['https'] })
|
||||||
|
photoUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VisitCheckOutDto {
|
||||||
|
@ApiProperty({ enum: CHECK_IN_METHODS })
|
||||||
|
@IsIn([...CHECK_IN_METHODS])
|
||||||
|
method!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@ValidateIf((dto: VisitCheckOutDto) => dto.method === 'nfc')
|
||||||
|
@IsString()
|
||||||
|
nfcId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@ValidateIf((dto: VisitCheckOutDto) => dto.method === 'qr')
|
||||||
|
@IsString()
|
||||||
|
qrCode?: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsLatitude()
|
||||||
|
latitude!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsLongitude()
|
||||||
|
longitude!: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl({ require_protocol: true, protocols: ['https'] })
|
||||||
|
photoUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateVisitStatusDto {
|
||||||
|
@ApiProperty({ enum: CORE_STATUSES })
|
||||||
|
@IsIn([...CORE_STATUSES])
|
||||||
|
status!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BulkIdsDto {
|
||||||
|
@ApiProperty({ type: [String], format: 'uuid' })
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
ids!: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BulkStatusDto {
|
||||||
|
@ApiProperty({ type: [String], format: 'uuid' })
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
ids!: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ enum: CORE_STATUSES })
|
||||||
|
@IsIn([...CORE_STATUSES])
|
||||||
|
status!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListVisitsQueryDto extends PaginationQueryDto {
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
employeeId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
customerId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Unix ms start of calendar day' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
date?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: CORE_STATUSES })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([...CORE_STATUSES])
|
||||||
|
status?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RelationDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
name!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserRelationDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
username!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VisitDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: RelationDto })
|
||||||
|
employee!: RelationDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: RelationDto })
|
||||||
|
customer!: RelationDto;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'uuid', nullable: true })
|
||||||
|
attendanceId!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'uuid', nullable: true })
|
||||||
|
planId!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'uuid', nullable: true })
|
||||||
|
planDestinationId!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
date!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
checkInAt!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: CHECK_IN_METHODS })
|
||||||
|
checkInMethod!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
checkInLatitude!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
checkInLongitude!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
checkInPhotoUrl!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
checkInDistanceMeters!: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
checkOutAt!: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: CHECK_IN_METHODS, nullable: true })
|
||||||
|
checkOutMethod!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
checkOutLatitude!: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
checkOutLongitude!: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
checkOutPhotoUrl!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
checkOutDistanceMeters!: number | null;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
status!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
createdAt!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
updatedAt!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: UserRelationDto })
|
||||||
|
createdBy!: UserRelationDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: UserRelationDto })
|
||||||
|
updatedBy!: UserRelationDto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
|
import type { CheckInMethod } from '../shared/check-in-verification';
|
||||||
|
|
||||||
|
export type Visit = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly employeeId: string;
|
||||||
|
readonly customerId: string;
|
||||||
|
readonly attendanceId: string | null;
|
||||||
|
readonly planId: string | null;
|
||||||
|
readonly planDestinationId: string | null;
|
||||||
|
readonly date: DateTime;
|
||||||
|
readonly checkInAt: DateTime;
|
||||||
|
readonly checkInMethod: CheckInMethod;
|
||||||
|
readonly checkInLatitude: number;
|
||||||
|
readonly checkInLongitude: number;
|
||||||
|
readonly checkInPhotoUrl: string | null;
|
||||||
|
readonly checkInDistanceMeters: number | null;
|
||||||
|
readonly checkOutAt: DateTime | null;
|
||||||
|
readonly checkOutMethod: CheckInMethod | null;
|
||||||
|
readonly checkOutLatitude: number | null;
|
||||||
|
readonly checkOutLongitude: number | null;
|
||||||
|
readonly checkOutPhotoUrl: string | null;
|
||||||
|
readonly checkOutDistanceMeters: number | null;
|
||||||
|
readonly status: Status;
|
||||||
|
readonly createdAt: DateTime;
|
||||||
|
readonly updatedAt: DateTime;
|
||||||
|
readonly createdBy: string;
|
||||||
|
readonly updatedBy: string;
|
||||||
|
readonly employee: { id: string; code: string; name: string } | null;
|
||||||
|
readonly customer: { id: string; code: string; name: string } | null;
|
||||||
|
readonly createdByUser: { id: string; username: string } | null;
|
||||||
|
readonly updatedByUser: { id: string; username: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListVisitsFilters = {
|
||||||
|
readonly employeeId?: string;
|
||||||
|
readonly customerId?: string;
|
||||||
|
readonly date?: number;
|
||||||
|
readonly status?: string;
|
||||||
|
readonly search?: string;
|
||||||
|
readonly orderBy?: string;
|
||||||
|
readonly orderType?: string;
|
||||||
|
readonly limit: number;
|
||||||
|
readonly offset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateVisitInput = {
|
||||||
|
readonly employeeId: string;
|
||||||
|
readonly customerId: string;
|
||||||
|
readonly attendanceId: string | null;
|
||||||
|
readonly planId: string | null;
|
||||||
|
readonly planDestinationId: string | null;
|
||||||
|
readonly date: DateTime;
|
||||||
|
readonly checkInAt: DateTime;
|
||||||
|
readonly checkInMethod: CheckInMethod;
|
||||||
|
readonly checkInLatitude: number;
|
||||||
|
readonly checkInLongitude: number;
|
||||||
|
readonly checkInPhotoUrl: string | null;
|
||||||
|
readonly checkInDistanceMeters: number | null;
|
||||||
|
readonly userId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CheckOutVisitInput = {
|
||||||
|
readonly checkOutAt: DateTime;
|
||||||
|
readonly checkOutMethod: CheckInMethod;
|
||||||
|
readonly checkOutLatitude: number;
|
||||||
|
readonly checkOutLongitude: number;
|
||||||
|
readonly checkOutPhotoUrl: string | null;
|
||||||
|
readonly checkOutDistanceMeters: number | null;
|
||||||
|
readonly userId: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiForbiddenResponse,
|
||||||
|
ApiNotFoundResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
ApiUnauthorizedResponse,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||||
|
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
type PaginationResponse,
|
||||||
|
PaginationMetaDto,
|
||||||
|
} from '../../../common/http/response';
|
||||||
|
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||||
|
import { FIELD_VISIT_PRIVILEGE_KEY } from '../shared/field-purpose';
|
||||||
|
import { ListVisitsQueryDto, VisitDto } from './dto/visit.dto';
|
||||||
|
import { VisitsService } from './visits.service';
|
||||||
|
|
||||||
|
@ApiTags('visits')
|
||||||
|
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||||
|
@Controller('visits')
|
||||||
|
export class VisitsReadController {
|
||||||
|
constructor(private readonly visitsService: VisitsService) {}
|
||||||
|
|
||||||
|
@Get('current')
|
||||||
|
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'view')
|
||||||
|
@ApiOperation({ summary: 'Get the open visit for the current user' })
|
||||||
|
@ApiOkResponse({ type: VisitDto })
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
current(@CurrentUser('id') userId: string): Promise<VisitDto | null> {
|
||||||
|
return this.visitsService.findCurrent(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Pagination()
|
||||||
|
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'view')
|
||||||
|
@ApiOperation({ summary: 'List visits' })
|
||||||
|
@ApiOkResponse({
|
||||||
|
schema: {
|
||||||
|
properties: {
|
||||||
|
data: {
|
||||||
|
type: 'array',
|
||||||
|
items: { $ref: '#/components/schemas/VisitDto' },
|
||||||
|
},
|
||||||
|
meta: { $ref: '#/components/schemas/PaginationMetaDto' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
list(
|
||||||
|
@Query() query: ListVisitsQueryDto,
|
||||||
|
): Promise<PaginationResponse<VisitDto>> {
|
||||||
|
return this.visitsService.list(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'view')
|
||||||
|
@ApiOperation({ summary: 'Get visit detail' })
|
||||||
|
@ApiOkResponse({ type: VisitDto })
|
||||||
|
@ApiNotFoundResponse()
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<VisitDto> {
|
||||||
|
return this.visitsService.findById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PaginationMetaDto;
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiCreatedResponse,
|
||||||
|
ApiForbiddenResponse,
|
||||||
|
ApiNoContentResponse,
|
||||||
|
ApiNotFoundResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
ApiUnauthorizedResponse,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||||
|
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
||||||
|
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||||
|
import { FIELD_VISIT_PRIVILEGE_KEY } from '../shared/field-purpose';
|
||||||
|
import {
|
||||||
|
BulkIdsDto,
|
||||||
|
BulkStatusDto,
|
||||||
|
UpdateVisitStatusDto,
|
||||||
|
VisitCheckInDto,
|
||||||
|
VisitCheckOutDto,
|
||||||
|
VisitDto,
|
||||||
|
} from './dto/visit.dto';
|
||||||
|
import { VisitsService } from './visits.service';
|
||||||
|
|
||||||
|
@ApiTags('visits')
|
||||||
|
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||||
|
@Controller('visits')
|
||||||
|
export class VisitsWriteController {
|
||||||
|
constructor(private readonly visitsService: VisitsService) {}
|
||||||
|
|
||||||
|
@Post('check-in')
|
||||||
|
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'create')
|
||||||
|
@ApiOperation({ summary: 'Check in at a customer location' })
|
||||||
|
@ApiCreatedResponse({ type: VisitDto })
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
checkIn(
|
||||||
|
@Body() dto: VisitCheckInDto,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
): Promise<VisitDto> {
|
||||||
|
return this.visitsService.checkIn(dto, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bulk-delete')
|
||||||
|
@HttpCode(200)
|
||||||
|
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'delete')
|
||||||
|
@ApiOperation({ summary: 'Bulk delete visits' })
|
||||||
|
@ApiOkResponse({ schema: { properties: { deleted: { type: 'number' } } } })
|
||||||
|
bulkDelete(@Body() dto: BulkIdsDto): Promise<{ deleted: number }> {
|
||||||
|
return this.visitsService.bulkDelete(dto.ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bulk-status')
|
||||||
|
@HttpCode(200)
|
||||||
|
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'update')
|
||||||
|
@ApiOperation({ summary: 'Bulk update visit status' })
|
||||||
|
@ApiOkResponse({ schema: { properties: { updated: { type: 'number' } } } })
|
||||||
|
bulkStatus(
|
||||||
|
@Body() dto: BulkStatusDto,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
): Promise<{ updated: number }> {
|
||||||
|
return this.visitsService.bulkUpdateStatus(dto.ids, dto.status, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/check-out')
|
||||||
|
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'update')
|
||||||
|
@ApiOperation({ summary: 'Check out from a customer visit' })
|
||||||
|
@ApiOkResponse({ type: VisitDto })
|
||||||
|
@ApiNotFoundResponse()
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
checkOut(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: VisitCheckOutDto,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
): Promise<VisitDto> {
|
||||||
|
return this.visitsService.checkOut(id, dto, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/status')
|
||||||
|
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'update')
|
||||||
|
@ApiOperation({ summary: 'Update visit status' })
|
||||||
|
@ApiOkResponse({ type: VisitDto })
|
||||||
|
updateStatus(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: UpdateVisitStatusDto,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
): Promise<VisitDto> {
|
||||||
|
return this.visitsService.updateStatus(id, dto.status, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HttpCode(204)
|
||||||
|
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'delete')
|
||||||
|
@ApiOperation({ summary: 'Delete visit' })
|
||||||
|
@ApiNoContentResponse()
|
||||||
|
@ApiNotFoundResponse()
|
||||||
|
delete(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||||
|
return this.visitsService.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { and, count, eq, ilike, inArray, isNull, or, SQL } from 'drizzle-orm';
|
||||||
|
import { alias } from 'drizzle-orm/pg-core';
|
||||||
|
import { toOrderClauses } from '../../../common/http/response/order-clause';
|
||||||
|
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
|
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||||
|
import { customers } from '../../../database/customers-table';
|
||||||
|
import { planDestinations } from '../../../database/plans-table';
|
||||||
|
import { visits, type VisitRow } from '../../../database/visits-table';
|
||||||
|
import { employees, users } from '../../../database/schema';
|
||||||
|
import type {
|
||||||
|
CheckOutVisitInput,
|
||||||
|
CreateVisitInput,
|
||||||
|
ListVisitsFilters,
|
||||||
|
Visit,
|
||||||
|
} from './visit';
|
||||||
|
import type { CheckInMethod } from '../shared/check-in-verification';
|
||||||
|
|
||||||
|
const VISIT_ORDER_COLUMNS = {
|
||||||
|
id: visits.id,
|
||||||
|
date: visits.date,
|
||||||
|
status: visits.status,
|
||||||
|
createdAt: visits.createdAt,
|
||||||
|
updatedAt: visits.updatedAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
const createdByUsers = alias(users, 'visit_created_by_users');
|
||||||
|
const updatedByUsers = alias(users, 'visit_updated_by_users');
|
||||||
|
|
||||||
|
type VisitJoinedRow = {
|
||||||
|
visit: VisitRow;
|
||||||
|
employee: typeof employees.$inferSelect;
|
||||||
|
customer: typeof customers.$inferSelect;
|
||||||
|
createdByUser: typeof users.$inferSelect | null;
|
||||||
|
updatedByUser: typeof users.$inferSelect | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class VisitsRepository {
|
||||||
|
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||||
|
|
||||||
|
async list(
|
||||||
|
filters: ListVisitsFilters,
|
||||||
|
): Promise<{ data: Visit[]; total: number }> {
|
||||||
|
const where = this.buildListWhere(filters);
|
||||||
|
const totalRows = await this.db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(visits)
|
||||||
|
.where(where);
|
||||||
|
const totalRow = totalRows[0];
|
||||||
|
|
||||||
|
const rows = await this.selectWithRelations()
|
||||||
|
.where(where)
|
||||||
|
.orderBy(
|
||||||
|
...toOrderClauses(VISIT_ORDER_COLUMNS, filters, [
|
||||||
|
{ column: 'date', type: 'DESC' },
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.limit(filters.limit)
|
||||||
|
.offset(filters.offset);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: rows.map((row) => this.toDomain(row)),
|
||||||
|
total: Number(totalRow?.total ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<Visit | null> {
|
||||||
|
const rows = await this.selectWithRelations()
|
||||||
|
.where(eq(visits.id, id))
|
||||||
|
.limit(1);
|
||||||
|
const row = rows[0];
|
||||||
|
return row ? this.toDomain(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOpenByEmployeeId(employeeId: string): Promise<Visit | null> {
|
||||||
|
const rows = await this.selectWithRelations()
|
||||||
|
.where(and(eq(visits.employeeId, employeeId), isNull(visits.checkOutAt)))
|
||||||
|
.limit(1);
|
||||||
|
const row = rows[0];
|
||||||
|
return row ? this.toDomain(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findPlanDestinationId(
|
||||||
|
planId: string,
|
||||||
|
customerId: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const rows = await this.db
|
||||||
|
.select({ id: planDestinations.id })
|
||||||
|
.from(planDestinations)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(planDestinations.planId, planId),
|
||||||
|
eq(planDestinations.customerId, customerId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return rows[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateVisitInput): Promise<Visit> {
|
||||||
|
const now = DateTime.fromUnixMs(Date.now());
|
||||||
|
try {
|
||||||
|
const inserted = await this.db
|
||||||
|
.insert(visits)
|
||||||
|
.values({
|
||||||
|
employeeId: input.employeeId,
|
||||||
|
customerId: input.customerId,
|
||||||
|
attendanceId: input.attendanceId,
|
||||||
|
planId: input.planId,
|
||||||
|
planDestinationId: input.planDestinationId,
|
||||||
|
date: input.date.value,
|
||||||
|
checkInAt: input.checkInAt.value,
|
||||||
|
checkInMethod: input.checkInMethod,
|
||||||
|
checkInLatitude: input.checkInLatitude,
|
||||||
|
checkInLongitude: input.checkInLongitude,
|
||||||
|
checkInPhotoUrl: input.checkInPhotoUrl,
|
||||||
|
checkInDistanceMeters: input.checkInDistanceMeters,
|
||||||
|
status: Status.create('active').value,
|
||||||
|
createdAt: now.value,
|
||||||
|
updatedAt: now.value,
|
||||||
|
createdBy: input.userId,
|
||||||
|
updatedBy: input.userId,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
const created = await this.findById(inserted[0].id);
|
||||||
|
if (!created) {
|
||||||
|
throw new NotFoundException('Visit not found');
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
} catch (error) {
|
||||||
|
this.rethrowUniqueViolation(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkOut(id: string, input: CheckOutVisitInput): Promise<Visit> {
|
||||||
|
const existing = await this.findById(id);
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('Visit not found');
|
||||||
|
}
|
||||||
|
if (existing.checkOutAt) {
|
||||||
|
throw new ConflictException('Visit is already checked out');
|
||||||
|
}
|
||||||
|
const now = DateTime.fromUnixMs(Date.now());
|
||||||
|
await this.db
|
||||||
|
.update(visits)
|
||||||
|
.set({
|
||||||
|
checkOutAt: input.checkOutAt.value,
|
||||||
|
checkOutMethod: input.checkOutMethod,
|
||||||
|
checkOutLatitude: input.checkOutLatitude,
|
||||||
|
checkOutLongitude: input.checkOutLongitude,
|
||||||
|
checkOutPhotoUrl: input.checkOutPhotoUrl,
|
||||||
|
checkOutDistanceMeters: input.checkOutDistanceMeters,
|
||||||
|
updatedAt: now.value,
|
||||||
|
updatedBy: input.userId,
|
||||||
|
})
|
||||||
|
.where(eq(visits.id, id));
|
||||||
|
const updated = await this.findById(id);
|
||||||
|
if (!updated) {
|
||||||
|
throw new NotFoundException('Visit not found');
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(
|
||||||
|
id: string,
|
||||||
|
status: Status,
|
||||||
|
userId: string,
|
||||||
|
): Promise<Visit> {
|
||||||
|
const now = DateTime.fromUnixMs(Date.now());
|
||||||
|
const updated = await this.db
|
||||||
|
.update(visits)
|
||||||
|
.set({
|
||||||
|
status: status.value,
|
||||||
|
updatedAt: now.value,
|
||||||
|
updatedBy: userId,
|
||||||
|
})
|
||||||
|
.where(eq(visits.id, id))
|
||||||
|
.returning({ id: visits.id });
|
||||||
|
if (updated.length === 0) {
|
||||||
|
throw new NotFoundException('Visit not found');
|
||||||
|
}
|
||||||
|
const row = await this.findById(id);
|
||||||
|
if (!row) {
|
||||||
|
throw new NotFoundException('Visit not found');
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async bulkUpdateStatus(
|
||||||
|
ids: string[],
|
||||||
|
status: Status,
|
||||||
|
userId: string,
|
||||||
|
): Promise<number> {
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const now = DateTime.fromUnixMs(Date.now());
|
||||||
|
const rows = await this.db
|
||||||
|
.update(visits)
|
||||||
|
.set({
|
||||||
|
status: status.value,
|
||||||
|
updatedAt: now.value,
|
||||||
|
updatedBy: userId,
|
||||||
|
})
|
||||||
|
.where(inArray(visits.id, ids))
|
||||||
|
.returning({ id: visits.id });
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
const deleted = await this.db
|
||||||
|
.delete(visits)
|
||||||
|
.where(eq(visits.id, id))
|
||||||
|
.returning({ id: visits.id });
|
||||||
|
if (deleted.length === 0) {
|
||||||
|
throw new NotFoundException('Visit not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async bulkDelete(ids: string[]): Promise<number> {
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const deleted = await this.db
|
||||||
|
.delete(visits)
|
||||||
|
.where(inArray(visits.id, ids))
|
||||||
|
.returning({ id: visits.id });
|
||||||
|
return deleted.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectWithRelations() {
|
||||||
|
return this.db
|
||||||
|
.select({
|
||||||
|
visit: visits,
|
||||||
|
employee: employees,
|
||||||
|
customer: customers,
|
||||||
|
createdByUser: createdByUsers,
|
||||||
|
updatedByUser: updatedByUsers,
|
||||||
|
})
|
||||||
|
.from(visits)
|
||||||
|
.innerJoin(employees, eq(visits.employeeId, employees.id))
|
||||||
|
.innerJoin(customers, eq(visits.customerId, customers.id))
|
||||||
|
.leftJoin(createdByUsers, eq(visits.createdBy, createdByUsers.id))
|
||||||
|
.leftJoin(updatedByUsers, eq(visits.updatedBy, updatedByUsers.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildListWhere(filters: ListVisitsFilters): SQL | undefined {
|
||||||
|
const parts: SQL[] = [];
|
||||||
|
if (filters.employeeId) {
|
||||||
|
parts.push(eq(visits.employeeId, filters.employeeId));
|
||||||
|
}
|
||||||
|
if (filters.customerId) {
|
||||||
|
parts.push(eq(visits.customerId, filters.customerId));
|
||||||
|
}
|
||||||
|
if (filters.date !== undefined) {
|
||||||
|
parts.push(eq(visits.date, filters.date));
|
||||||
|
}
|
||||||
|
if (filters.status) {
|
||||||
|
parts.push(eq(visits.status, filters.status));
|
||||||
|
}
|
||||||
|
if (filters.search) {
|
||||||
|
const search = or(
|
||||||
|
ilike(employees.code, `%${filters.search}%`),
|
||||||
|
ilike(employees.name, `%${filters.search}%`),
|
||||||
|
ilike(customers.code, `%${filters.search}%`),
|
||||||
|
ilike(customers.name, `%${filters.search}%`),
|
||||||
|
);
|
||||||
|
if (search) {
|
||||||
|
parts.push(search);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return parts.length === 1 ? parts[0] : and(...parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDomain(row: VisitJoinedRow): Visit {
|
||||||
|
const visit = row.visit;
|
||||||
|
return {
|
||||||
|
id: visit.id,
|
||||||
|
employeeId: visit.employeeId,
|
||||||
|
customerId: visit.customerId,
|
||||||
|
attendanceId: visit.attendanceId,
|
||||||
|
planId: visit.planId,
|
||||||
|
planDestinationId: visit.planDestinationId,
|
||||||
|
date: DateTime.fromUnixMs(visit.date),
|
||||||
|
checkInAt: DateTime.fromUnixMs(visit.checkInAt),
|
||||||
|
checkInMethod: visit.checkInMethod as CheckInMethod,
|
||||||
|
checkInLatitude: visit.checkInLatitude,
|
||||||
|
checkInLongitude: visit.checkInLongitude,
|
||||||
|
checkInPhotoUrl: visit.checkInPhotoUrl,
|
||||||
|
checkInDistanceMeters: visit.checkInDistanceMeters,
|
||||||
|
checkOutAt: visit.checkOutAt
|
||||||
|
? DateTime.fromUnixMs(visit.checkOutAt)
|
||||||
|
: null,
|
||||||
|
checkOutMethod: visit.checkOutMethod as CheckInMethod | null,
|
||||||
|
checkOutLatitude: visit.checkOutLatitude,
|
||||||
|
checkOutLongitude: visit.checkOutLongitude,
|
||||||
|
checkOutPhotoUrl: visit.checkOutPhotoUrl,
|
||||||
|
checkOutDistanceMeters: visit.checkOutDistanceMeters,
|
||||||
|
status: Status.create(visit.status),
|
||||||
|
createdAt: DateTime.fromUnixMs(visit.createdAt),
|
||||||
|
updatedAt: DateTime.fromUnixMs(visit.updatedAt),
|
||||||
|
createdBy: visit.createdBy,
|
||||||
|
updatedBy: visit.updatedBy,
|
||||||
|
employee: {
|
||||||
|
id: row.employee.id,
|
||||||
|
code: row.employee.code,
|
||||||
|
name: row.employee.name,
|
||||||
|
},
|
||||||
|
customer: {
|
||||||
|
id: row.customer.id,
|
||||||
|
code: row.customer.code,
|
||||||
|
name: row.customer.name,
|
||||||
|
},
|
||||||
|
createdByUser: row.createdByUser
|
||||||
|
? { id: row.createdByUser.id, username: row.createdByUser.username }
|
||||||
|
: null,
|
||||||
|
updatedByUser: row.updatedByUser
|
||||||
|
? { id: row.updatedByUser.id, username: row.updatedByUser.username }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private rethrowUniqueViolation(error: unknown): never {
|
||||||
|
let current: unknown = error;
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
if (!current || typeof current !== 'object') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const obj = current as { code?: string; cause?: unknown };
|
||||||
|
if (obj.code === '23505') {
|
||||||
|
throw new ConflictException(
|
||||||
|
'A visit is already open for this employee',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
current = obj.cause;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import type { PaginationResponse } from '../../../common/http/response';
|
||||||
|
import {
|
||||||
|
pickRelation,
|
||||||
|
pickUserRelation,
|
||||||
|
DEFAULT_RELATION_FIELDS,
|
||||||
|
toListPage,
|
||||||
|
} from '../../../common/http/response';
|
||||||
|
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
|
import { CustomersService } from '../../configuration/customers/customers.service';
|
||||||
|
import { EmployeesService } from '../../configuration/employees/employees.service';
|
||||||
|
import { AttendancesRepository } from '../attendances/attendances.repository';
|
||||||
|
import { CompanySettingsService } from '../settings/company-settings.service';
|
||||||
|
import {
|
||||||
|
isCheckInMethod,
|
||||||
|
verifyCustomerCheckIn,
|
||||||
|
type CheckInPayload,
|
||||||
|
type CheckInVerificationOptions,
|
||||||
|
} from '../shared/check-in-verification';
|
||||||
|
import { FIELD_VISIT_PRIVILEGE_KEY } from '../shared/field-purpose';
|
||||||
|
import { TimelineActivitiesService } from '../timeline/timeline-activities.service';
|
||||||
|
import type { Visit } from './visit';
|
||||||
|
import type {
|
||||||
|
ListVisitsQueryDto,
|
||||||
|
VisitCheckInDto,
|
||||||
|
VisitCheckOutDto,
|
||||||
|
VisitDto,
|
||||||
|
} from './dto/visit.dto';
|
||||||
|
import { VisitsRepository } from './visits.repository';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class VisitsService {
|
||||||
|
constructor(
|
||||||
|
private readonly visitsRepository: VisitsRepository,
|
||||||
|
private readonly attendancesRepository: AttendancesRepository,
|
||||||
|
private readonly customersService: CustomersService,
|
||||||
|
private readonly employeesService: EmployeesService,
|
||||||
|
private readonly companySettingsService: CompanySettingsService,
|
||||||
|
private readonly timelineActivitiesService: TimelineActivitiesService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(query: ListVisitsQueryDto): Promise<PaginationResponse<VisitDto>> {
|
||||||
|
const page = toListPage(query);
|
||||||
|
const { data, total } = await this.visitsRepository.list({
|
||||||
|
employeeId: query.employeeId,
|
||||||
|
customerId: query.customerId,
|
||||||
|
date: query.date,
|
||||||
|
status: query.status,
|
||||||
|
search: query.search,
|
||||||
|
orderBy: query.orderBy,
|
||||||
|
orderType: query.orderType,
|
||||||
|
limit: page.limit,
|
||||||
|
offset: page.offset,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
data: data.map((item) => this.toItem(item)),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<VisitDto> {
|
||||||
|
const visit = await this.visitsRepository.findById(id);
|
||||||
|
if (!visit) {
|
||||||
|
throw new NotFoundException('Visit not found');
|
||||||
|
}
|
||||||
|
return this.toItem(visit);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findCurrent(userId: string): Promise<VisitDto | null> {
|
||||||
|
const employee = await this.employeesService.requireByUserId(userId);
|
||||||
|
const visit = await this.visitsRepository.findOpenByEmployeeId(employee.id);
|
||||||
|
return visit ? this.toItem(visit) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkIn(dto: VisitCheckInDto, userId: string): Promise<VisitDto> {
|
||||||
|
const employee = await this.employeesService.requireByUserId(userId);
|
||||||
|
const openVisit = await this.visitsRepository.findOpenByEmployeeId(
|
||||||
|
employee.id,
|
||||||
|
);
|
||||||
|
if (openVisit) {
|
||||||
|
throw new ConflictException('A customer visit is already open');
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAttendance =
|
||||||
|
await this.attendancesRepository.findOpenByEmployeeId(employee.id);
|
||||||
|
if (!openAttendance) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Branch check-in is required before visiting a customer',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const customer = await this.customersService.findById(dto.customerId);
|
||||||
|
const radiusMeters =
|
||||||
|
await this.companySettingsService.requireCheckInRadiusMeters();
|
||||||
|
const payload = this.toPayload(dto);
|
||||||
|
const verified = verifyCustomerCheckIn(
|
||||||
|
{
|
||||||
|
code: customer.code,
|
||||||
|
nfcId: customer.nfcId,
|
||||||
|
latitude: customer.latitude,
|
||||||
|
longitude: customer.longitude,
|
||||||
|
},
|
||||||
|
payload,
|
||||||
|
radiusMeters,
|
||||||
|
this.gpsVerificationOptions(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let planDestinationId: string | null = null;
|
||||||
|
if (dto.planId) {
|
||||||
|
planDestinationId = await this.visitsRepository.findPlanDestinationId(
|
||||||
|
dto.planId,
|
||||||
|
dto.customerId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = DateTime.fromUnixMs(Date.now());
|
||||||
|
const created = await this.visitsRepository.create({
|
||||||
|
employeeId: employee.id,
|
||||||
|
customerId: dto.customerId,
|
||||||
|
attendanceId: openAttendance.id,
|
||||||
|
planId: dto.planId ?? null,
|
||||||
|
planDestinationId,
|
||||||
|
date: now.startOfDay(),
|
||||||
|
checkInAt: now,
|
||||||
|
checkInMethod: verified.method,
|
||||||
|
checkInLatitude: verified.latitude,
|
||||||
|
checkInLongitude: verified.longitude,
|
||||||
|
checkInPhotoUrl: verified.photoUrl,
|
||||||
|
checkInDistanceMeters: verified.distanceMeters,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
await this.timelineActivitiesService.recordIfLocated({
|
||||||
|
employeeId: employee.id,
|
||||||
|
type: 'customer_check_in',
|
||||||
|
sourceType: 'visit',
|
||||||
|
sourceId: created.id,
|
||||||
|
latitude: verified.latitude,
|
||||||
|
longitude: verified.longitude,
|
||||||
|
recordedAt: now.value,
|
||||||
|
customerId: dto.customerId,
|
||||||
|
visitId: created.id,
|
||||||
|
});
|
||||||
|
return this.toItem(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkOut(
|
||||||
|
id: string,
|
||||||
|
dto: VisitCheckOutDto,
|
||||||
|
userId: string,
|
||||||
|
): Promise<VisitDto> {
|
||||||
|
const employee = await this.employeesService.requireByUserId(userId);
|
||||||
|
const visit = await this.visitsRepository.findById(id);
|
||||||
|
if (!visit) {
|
||||||
|
throw new NotFoundException('Visit not found');
|
||||||
|
}
|
||||||
|
if (visit.employeeId !== employee.id) {
|
||||||
|
throw new BadRequestException('Visit does not belong to this user');
|
||||||
|
}
|
||||||
|
if (visit.checkOutAt) {
|
||||||
|
throw new ConflictException('Visit is already checked out');
|
||||||
|
}
|
||||||
|
|
||||||
|
const customer = await this.customersService.findById(visit.customerId);
|
||||||
|
const radiusMeters =
|
||||||
|
await this.companySettingsService.requireCheckInRadiusMeters();
|
||||||
|
const payload = this.toPayload(dto);
|
||||||
|
const verified = verifyCustomerCheckIn(
|
||||||
|
{
|
||||||
|
code: customer.code,
|
||||||
|
nfcId: customer.nfcId,
|
||||||
|
latitude: customer.latitude,
|
||||||
|
longitude: customer.longitude,
|
||||||
|
},
|
||||||
|
payload,
|
||||||
|
radiusMeters,
|
||||||
|
this.gpsVerificationOptions(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = await this.visitsRepository.checkOut(id, {
|
||||||
|
checkOutAt: DateTime.fromUnixMs(Date.now()),
|
||||||
|
checkOutMethod: verified.method,
|
||||||
|
checkOutLatitude: verified.latitude,
|
||||||
|
checkOutLongitude: verified.longitude,
|
||||||
|
checkOutPhotoUrl: verified.photoUrl,
|
||||||
|
checkOutDistanceMeters: verified.distanceMeters,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
await this.timelineActivitiesService.recordIfLocated({
|
||||||
|
employeeId: employee.id,
|
||||||
|
type: 'customer_check_out',
|
||||||
|
sourceType: 'visit',
|
||||||
|
sourceId: updated.id,
|
||||||
|
latitude: verified.latitude,
|
||||||
|
longitude: verified.longitude,
|
||||||
|
recordedAt: updated.checkOutAt?.value,
|
||||||
|
customerId: visit.customerId,
|
||||||
|
visitId: updated.id,
|
||||||
|
});
|
||||||
|
return this.toItem(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(
|
||||||
|
id: string,
|
||||||
|
statusRaw: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<VisitDto> {
|
||||||
|
const status = Status.create(statusRaw);
|
||||||
|
const updated = await this.visitsRepository.updateStatus(
|
||||||
|
id,
|
||||||
|
status,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
return this.toItem(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async bulkUpdateStatus(
|
||||||
|
ids: string[],
|
||||||
|
statusRaw: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<{ updated: number }> {
|
||||||
|
const status = Status.create(statusRaw);
|
||||||
|
const updated = await this.visitsRepository.bulkUpdateStatus(
|
||||||
|
ids,
|
||||||
|
status,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
return { updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await this.visitsRepository.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async bulkDelete(ids: string[]): Promise<{ deleted: number }> {
|
||||||
|
const deleted = await this.visitsRepository.bulkDelete(ids);
|
||||||
|
return { deleted };
|
||||||
|
}
|
||||||
|
|
||||||
|
toItem(visit: Visit): VisitDto {
|
||||||
|
return {
|
||||||
|
id: visit.id,
|
||||||
|
employee: pickRelation(visit.employee, DEFAULT_RELATION_FIELDS)!,
|
||||||
|
customer: pickRelation(visit.customer, DEFAULT_RELATION_FIELDS)!,
|
||||||
|
attendanceId: visit.attendanceId,
|
||||||
|
planId: visit.planId,
|
||||||
|
planDestinationId: visit.planDestinationId,
|
||||||
|
date: visit.date.value,
|
||||||
|
checkInAt: visit.checkInAt.value,
|
||||||
|
checkInMethod: visit.checkInMethod,
|
||||||
|
checkInLatitude: visit.checkInLatitude,
|
||||||
|
checkInLongitude: visit.checkInLongitude,
|
||||||
|
checkInPhotoUrl: visit.checkInPhotoUrl,
|
||||||
|
checkInDistanceMeters: visit.checkInDistanceMeters,
|
||||||
|
checkOutAt: visit.checkOutAt?.value ?? null,
|
||||||
|
checkOutMethod: visit.checkOutMethod,
|
||||||
|
checkOutLatitude: visit.checkOutLatitude,
|
||||||
|
checkOutLongitude: visit.checkOutLongitude,
|
||||||
|
checkOutPhotoUrl: visit.checkOutPhotoUrl,
|
||||||
|
checkOutDistanceMeters: visit.checkOutDistanceMeters,
|
||||||
|
status: visit.status.value,
|
||||||
|
createdAt: visit.createdAt.value,
|
||||||
|
updatedAt: visit.updatedAt.value,
|
||||||
|
createdBy: pickUserRelation(
|
||||||
|
visit.createdByUser ?? { id: visit.createdBy, username: '' },
|
||||||
|
),
|
||||||
|
updatedBy: pickUserRelation(
|
||||||
|
visit.updatedByUser ?? { id: visit.updatedBy, username: '' },
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private gpsVerificationOptions(): CheckInVerificationOptions {
|
||||||
|
return {
|
||||||
|
skipGpsValidation:
|
||||||
|
this.config.get<boolean>('SKIP_GPS_VALIDATION') === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toPayload(dto: VisitCheckInDto | VisitCheckOutDto): CheckInPayload {
|
||||||
|
if (!isCheckInMethod(dto.method)) {
|
||||||
|
throw new BadRequestException('Invalid check-in method');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
method: dto.method,
|
||||||
|
nfcId: dto.nfcId,
|
||||||
|
qrCode: dto.qrCode,
|
||||||
|
latitude: dto.latitude,
|
||||||
|
longitude: dto.longitude,
|
||||||
|
photoUrl: dto.photoUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void FIELD_VISIT_PRIVILEGE_KEY;
|
||||||
@@ -146,7 +146,7 @@ export class PrivilegeDetailDto {
|
|||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ format: 'uuid' })
|
||||||
privilegeKeyId!: string;
|
privilegeKeyId!: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'SALES.INVOICE' })
|
@ApiProperty({ example: 'ADMIN.SALES.ACTIVITIES.INVOICE' })
|
||||||
keyCode!: string;
|
keyCode!: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'Sales Invoice' })
|
@ApiProperty({ example: 'Sales Invoice' })
|
||||||
@@ -197,7 +197,7 @@ export class PrivilegeKeyDto {
|
|||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ format: 'uuid' })
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'PRIVILEGES' })
|
@ApiProperty({ example: 'ADMIN.SETTINGS.USER.PRIVILEGES' })
|
||||||
code!: string;
|
code!: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'Privileges' })
|
@ApiProperty({ example: 'Privileges' })
|
||||||
|
|||||||
@@ -17,14 +17,15 @@ describe('privilege-action', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('privilege-key-code', () => {
|
describe('privilege-key-code', () => {
|
||||||
it('accepts dotted uppercase module levels', () => {
|
it('accepts 3- and 4-part dotted uppercase codes', () => {
|
||||||
expect(isValidPrivilegeKeyCode('PRIVILEGES')).toBe(true);
|
expect(isValidPrivilegeKeyCode('ADMIN.SETTINGS.USER.PRIVILEGES')).toBe(true);
|
||||||
expect(isValidPrivilegeKeyCode('SALES.INVOICE')).toBe(true);
|
expect(isValidPrivilegeKeyCode('ADMIN.SALES.ACTIVITIES.INVOICE')).toBe(true);
|
||||||
expect(isValidPrivilegeKeyCode('SALES.INVOICE.LINE')).toBe(true);
|
expect(isValidPrivilegeKeyCode('MOBILE.SALES.PLAN')).toBe(true);
|
||||||
expect(assertPrivilegeKeyCode('USERS')).toBe('USERS');
|
expect(assertPrivilegeKeyCode('MOBILE.SALES.PLAN')).toBe('MOBILE.SALES.PLAN');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects invalid codes', () => {
|
it('rejects invalid codes', () => {
|
||||||
|
expect(isValidPrivilegeKeyCode('PRIVILEGES')).toBe(false);
|
||||||
expect(isValidPrivilegeKeyCode('sales.invoice')).toBe(false);
|
expect(isValidPrivilegeKeyCode('sales.invoice')).toBe(false);
|
||||||
expect(isValidPrivilegeKeyCode('SALES.')).toBe(false);
|
expect(isValidPrivilegeKeyCode('SALES.')).toBe(false);
|
||||||
expect(isValidPrivilegeKeyCode('.SALES')).toBe(false);
|
expect(isValidPrivilegeKeyCode('.SALES')).toBe(false);
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import {
|
||||||
|
assertPrivilegeKeyCode,
|
||||||
|
isValidPrivilegeKeyCode,
|
||||||
|
parsePrivilegeKeyCode,
|
||||||
|
} from './privilege-key-code';
|
||||||
|
|
||||||
|
describe('privilege-key-code', () => {
|
||||||
|
describe('isValidPrivilegeKeyCode', () => {
|
||||||
|
it('accepts 3-part keys', () => {
|
||||||
|
expect(isValidPrivilegeKeyCode('MOBILE.SALES.PLAN')).toBe(true);
|
||||||
|
expect(isValidPrivilegeKeyCode('ADMIN.SALES.REPORT')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts 4-part keys', () => {
|
||||||
|
expect(isValidPrivilegeKeyCode('MOBILE.SALES.PLAN.ATTENDANCE')).toBe(true);
|
||||||
|
expect(isValidPrivilegeKeyCode('ADMIN.SALES.ACTIVITIES.PLAN')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects fewer than 3 segments', () => {
|
||||||
|
expect(isValidPrivilegeKeyCode('PRIVILEGES')).toBe(false);
|
||||||
|
expect(isValidPrivilegeKeyCode('SALES.PLAN')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects more than 4 segments', () => {
|
||||||
|
expect(isValidPrivilegeKeyCode('A.B.C.D.E')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid characters', () => {
|
||||||
|
expect(isValidPrivilegeKeyCode('mobile.sales.plan')).toBe(false);
|
||||||
|
expect(isValidPrivilegeKeyCode('MOBILE..PLAN')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('assertPrivilegeKeyCode', () => {
|
||||||
|
it('returns the code when valid', () => {
|
||||||
|
expect(assertPrivilegeKeyCode('MOBILE.SALES.PLAN')).toBe('MOBILE.SALES.PLAN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when invalid', () => {
|
||||||
|
expect(() => assertPrivilegeKeyCode('PRIVILEGES')).toThrow(TypeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parsePrivilegeKeyCode', () => {
|
||||||
|
it('parses a 3-part key with null submodule', () => {
|
||||||
|
expect(parsePrivilegeKeyCode('MOBILE.SALES.PLAN')).toEqual({
|
||||||
|
group: 'MOBILE',
|
||||||
|
parent: 'SALES',
|
||||||
|
module: 'PLAN',
|
||||||
|
submodule: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a 4-part key', () => {
|
||||||
|
expect(parsePrivilegeKeyCode('MOBILE.SALES.PLAN.ATTENDANCE')).toEqual({
|
||||||
|
group: 'MOBILE',
|
||||||
|
parent: 'SALES',
|
||||||
|
module: 'PLAN',
|
||||||
|
submodule: 'ATTENDANCE',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for invalid codes', () => {
|
||||||
|
expect(() => parsePrivilegeKeyCode('PRIVILEGES')).toThrow(TypeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
/** Dotted uppercase module levels: MODULE / MODULE.RESOURCE / MODULE.RESOURCE.SUB */
|
/** Dotted uppercase: Group.Parent.Module or Group.Parent.Module.Submodule */
|
||||||
export const PRIVILEGE_KEY_CODE_PATTERN =
|
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 {
|
export function isValidPrivilegeKeyCode(code: string): boolean {
|
||||||
return typeof code === 'string' && PRIVILEGE_KEY_CODE_PATTERN.test(code);
|
return typeof code === 'string' && PRIVILEGE_KEY_CODE_PATTERN.test(code);
|
||||||
@@ -12,3 +19,14 @@ export function assertPrivilegeKeyCode(code: string): string {
|
|||||||
}
|
}
|
||||||
return code;
|
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()
|
@Get()
|
||||||
@Pagination()
|
@Pagination()
|
||||||
@RequirePrivilege('PRIVILEGES', 'view')
|
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'view')
|
||||||
@ApiOperation({ summary: 'List privilege keys catalog' })
|
@ApiOperation({ summary: 'List privilege keys catalog' })
|
||||||
@ApiOkResponse({
|
@ApiOkResponse({
|
||||||
schema: {
|
schema: {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export class PrivilegesReadController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@Pagination()
|
@Pagination()
|
||||||
@RequirePrivilege('PRIVILEGES', 'view')
|
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'view')
|
||||||
@ApiOperation({ summary: 'List privileges' })
|
@ApiOperation({ summary: 'List privileges' })
|
||||||
@ApiOkResponse({
|
@ApiOkResponse({
|
||||||
schema: {
|
schema: {
|
||||||
@@ -52,7 +52,7 @@ export class PrivilegesReadController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RequirePrivilege('PRIVILEGES', 'view')
|
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'view')
|
||||||
@ApiOperation({ summary: 'Get privilege detail with matrix' })
|
@ApiOperation({ summary: 'Get privilege detail with matrix' })
|
||||||
@ApiOkResponse({ type: PrivilegeDetailResponseDto })
|
@ApiOkResponse({ type: PrivilegeDetailResponseDto })
|
||||||
@ApiNotFoundResponse()
|
@ApiNotFoundResponse()
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export class PrivilegesWriteController {
|
|||||||
constructor(private readonly privilegesService: PrivilegesService) {}
|
constructor(private readonly privilegesService: PrivilegesService) {}
|
||||||
|
|
||||||
@Post('import')
|
@Post('import')
|
||||||
@RequirePrivilege('PRIVILEGES', 'import')
|
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'import')
|
||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
FileInterceptor('file', {
|
FileInterceptor('file', {
|
||||||
limits: { fileSize: 1_048_576 },
|
limits: { fileSize: 1_048_576 },
|
||||||
@@ -90,7 +90,7 @@ export class PrivilegesWriteController {
|
|||||||
|
|
||||||
@Post('bulk-delete')
|
@Post('bulk-delete')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@RequirePrivilege('PRIVILEGES', 'delete')
|
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'delete')
|
||||||
@ApiOperation({ summary: 'Bulk delete privileges' })
|
@ApiOperation({ summary: 'Bulk delete privileges' })
|
||||||
@ApiOkResponse({
|
@ApiOkResponse({
|
||||||
schema: { properties: { deleted: { type: 'number' } } },
|
schema: { properties: { deleted: { type: 'number' } } },
|
||||||
@@ -103,7 +103,7 @@ export class PrivilegesWriteController {
|
|||||||
|
|
||||||
@Post('bulk-status')
|
@Post('bulk-status')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@RequirePrivilege('PRIVILEGES', 'update')
|
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'update')
|
||||||
@ApiOperation({ summary: 'Bulk update privilege status' })
|
@ApiOperation({ summary: 'Bulk update privilege status' })
|
||||||
@ApiOkResponse({
|
@ApiOkResponse({
|
||||||
schema: { properties: { updated: { type: 'number' } } },
|
schema: { properties: { updated: { type: 'number' } } },
|
||||||
@@ -118,7 +118,7 @@ export class PrivilegesWriteController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RequirePrivilege('PRIVILEGES', 'create')
|
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'create')
|
||||||
@ApiOperation({ summary: 'Create privilege' })
|
@ApiOperation({ summary: 'Create privilege' })
|
||||||
@ApiCreatedResponse({ type: PrivilegeDetailResponseDto })
|
@ApiCreatedResponse({ type: PrivilegeDetailResponseDto })
|
||||||
@ApiUnauthorizedResponse()
|
@ApiUnauthorizedResponse()
|
||||||
@@ -137,7 +137,7 @@ export class PrivilegesWriteController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/status')
|
@Patch(':id/status')
|
||||||
@RequirePrivilege('PRIVILEGES', 'update')
|
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'update')
|
||||||
@ApiOperation({ summary: 'Update privilege status' })
|
@ApiOperation({ summary: 'Update privilege status' })
|
||||||
@ApiOkResponse({ type: PrivilegeDto })
|
@ApiOkResponse({ type: PrivilegeDto })
|
||||||
@ApiNotFoundResponse()
|
@ApiNotFoundResponse()
|
||||||
@@ -152,7 +152,7 @@ export class PrivilegesWriteController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@RequirePrivilege('PRIVILEGES', 'update')
|
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'update')
|
||||||
@ApiOperation({ summary: 'Update privilege (not status)' })
|
@ApiOperation({ summary: 'Update privilege (not status)' })
|
||||||
@ApiOkResponse({ type: PrivilegeDetailResponseDto })
|
@ApiOkResponse({ type: PrivilegeDetailResponseDto })
|
||||||
@ApiNotFoundResponse()
|
@ApiNotFoundResponse()
|
||||||
@@ -173,7 +173,7 @@ export class PrivilegesWriteController {
|
|||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
@RequirePrivilege('PRIVILEGES', 'delete')
|
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'delete')
|
||||||
@ApiOperation({ summary: 'Delete privilege' })
|
@ApiOperation({ summary: 'Delete privilege' })
|
||||||
@ApiNoContentResponse()
|
@ApiNoContentResponse()
|
||||||
@ApiNotFoundResponse()
|
@ApiNotFoundResponse()
|
||||||
|
|||||||
@@ -389,6 +389,39 @@ export class PrivilegesRepository {
|
|||||||
return row?.value === true;
|
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(
|
async getPermissionsMap(
|
||||||
privilegeId: string,
|
privilegeId: string,
|
||||||
): Promise<Record<string, Record<PrivilegeAction, boolean>>> {
|
): Promise<Record<string, Record<PrivilegeAction, boolean>>> {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ describe('PrivilegesService', () => {
|
|||||||
| 'listKeys'
|
| 'listKeys'
|
||||||
| 'findKeyById'
|
| 'findKeyById'
|
||||||
| 'checkPermission'
|
| 'checkPermission'
|
||||||
|
| 'checkAnyPermission'
|
||||||
| 'getPermissionsMap'
|
| 'getPermissionsMap'
|
||||||
>
|
>
|
||||||
>;
|
>;
|
||||||
@@ -56,6 +57,7 @@ describe('PrivilegesService', () => {
|
|||||||
listKeys: jest.fn(),
|
listKeys: jest.fn(),
|
||||||
findKeyById: jest.fn(),
|
findKeyById: jest.fn(),
|
||||||
checkPermission: jest.fn(),
|
checkPermission: jest.fn(),
|
||||||
|
checkAnyPermission: jest.fn(),
|
||||||
getPermissionsMap: jest.fn(),
|
getPermissionsMap: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -140,7 +142,44 @@ describe('PrivilegesService', () => {
|
|||||||
it('checkPermission delegates', async () => {
|
it('checkPermission delegates', async () => {
|
||||||
repository.checkPermission.mockResolvedValue(true);
|
repository.checkPermission.mockResolvedValue(true);
|
||||||
await expect(
|
await expect(
|
||||||
service.checkPermission('user-1', 'PRIVILEGES', 'view'),
|
service.checkPermission(
|
||||||
|
'user-1',
|
||||||
|
'ADMIN.SETTINGS.USER.PRIVILEGES',
|
||||||
|
'view',
|
||||||
|
),
|
||||||
).resolves.toBe(true);
|
).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);
|
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(
|
async getPermissionsMap(
|
||||||
privilegeId: string,
|
privilegeId: string,
|
||||||
): Promise<Record<string, Record<PrivilegeAction, boolean>>> {
|
): Promise<Record<string, Record<PrivilegeAction, boolean>>> {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const LOGISTICS_PREFIX = `${REPORT_GROUP.LOGISTICS_REPORT}__`;
|
|||||||
export const packingSlipReport: ReportConfigEntity = {
|
export const packingSlipReport: ReportConfigEntity = {
|
||||||
groupName: REPORT_GROUP.LOGISTICS_REPORT,
|
groupName: REPORT_GROUP.LOGISTICS_REPORT,
|
||||||
uniqueName: `${LOGISTICS_PREFIX}packing_slip`,
|
uniqueName: `${LOGISTICS_PREFIX}packing_slip`,
|
||||||
privilegeKey: 'LOGISTICS.REPORT',
|
privilegeKey: 'ADMIN.LOGISTICS.REPORT',
|
||||||
label: 'Report Packing Slip',
|
label: 'Report Packing Slip',
|
||||||
tableSchema: `packing_slips main
|
tableSchema: `packing_slips main
|
||||||
JOIN customers cust ON cust.id = main.customer_id`,
|
JOIN customers cust ON cust.id = main.customer_id`,
|
||||||
@@ -89,7 +89,7 @@ export const packingSlipReport: ReportConfigEntity = {
|
|||||||
export const deliveryPlanReport: ReportConfigEntity = {
|
export const deliveryPlanReport: ReportConfigEntity = {
|
||||||
groupName: REPORT_GROUP.LOGISTICS_REPORT,
|
groupName: REPORT_GROUP.LOGISTICS_REPORT,
|
||||||
uniqueName: `${LOGISTICS_PREFIX}delivery_plan`,
|
uniqueName: `${LOGISTICS_PREFIX}delivery_plan`,
|
||||||
privilegeKey: 'LOGISTICS.REPORT',
|
privilegeKey: 'ADMIN.LOGISTICS.REPORT',
|
||||||
label: 'Report Delivery Plan',
|
label: 'Report Delivery Plan',
|
||||||
tableSchema: `plans main
|
tableSchema: `plans main
|
||||||
JOIN employees emp ON emp.id = main.employee_id
|
JOIN employees emp ON emp.id = main.employee_id
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const LOGISTICS_PREFIX = `${REPORT_GROUP.LOGISTICS_REPORT}__`;
|
|||||||
export const salesOrderReport: ReportConfigEntity = {
|
export const salesOrderReport: ReportConfigEntity = {
|
||||||
groupName: REPORT_GROUP.SALES_REPORT,
|
groupName: REPORT_GROUP.SALES_REPORT,
|
||||||
uniqueName: `${SALES_PREFIX}sales_order`,
|
uniqueName: `${SALES_PREFIX}sales_order`,
|
||||||
privilegeKey: 'SALES.REPORT',
|
privilegeKey: 'ADMIN.SALES.REPORT',
|
||||||
label: 'Report Sales Order',
|
label: 'Report Sales Order',
|
||||||
tableSchema: `sales_orders main
|
tableSchema: `sales_orders main
|
||||||
JOIN customers cust ON cust.id = main.customer_id
|
JOIN customers cust ON cust.id = main.customer_id
|
||||||
@@ -138,7 +138,7 @@ export const salesOrderReport: ReportConfigEntity = {
|
|||||||
export const salesRequestReport: ReportConfigEntity = {
|
export const salesRequestReport: ReportConfigEntity = {
|
||||||
groupName: REPORT_GROUP.SALES_REPORT,
|
groupName: REPORT_GROUP.SALES_REPORT,
|
||||||
uniqueName: `${SALES_PREFIX}sales_request`,
|
uniqueName: `${SALES_PREFIX}sales_request`,
|
||||||
privilegeKey: 'SALES.REPORT',
|
privilegeKey: 'ADMIN.SALES.REPORT',
|
||||||
label: 'Report Request Order',
|
label: 'Report Request Order',
|
||||||
tableSchema: `sales_requests main
|
tableSchema: `sales_requests main
|
||||||
JOIN customers cust ON cust.id = main.customer_id
|
JOIN customers cust ON cust.id = main.customer_id
|
||||||
@@ -250,7 +250,7 @@ export const salesRequestReport: ReportConfigEntity = {
|
|||||||
export const salesInvoiceReport: ReportConfigEntity = {
|
export const salesInvoiceReport: ReportConfigEntity = {
|
||||||
groupName: REPORT_GROUP.SALES_REPORT,
|
groupName: REPORT_GROUP.SALES_REPORT,
|
||||||
uniqueName: `${SALES_PREFIX}sales_invoice`,
|
uniqueName: `${SALES_PREFIX}sales_invoice`,
|
||||||
privilegeKey: 'SALES.REPORT',
|
privilegeKey: 'ADMIN.SALES.REPORT',
|
||||||
label: 'Report Invoice',
|
label: 'Report Invoice',
|
||||||
tableSchema: `sales_invoices main
|
tableSchema: `sales_invoices main
|
||||||
JOIN customers cust ON cust.id = main.customer_id
|
JOIN customers cust ON cust.id = main.customer_id
|
||||||
@@ -365,7 +365,7 @@ export const salesInvoiceReport: ReportConfigEntity = {
|
|||||||
export const salesPaymentReport: ReportConfigEntity = {
|
export const salesPaymentReport: ReportConfigEntity = {
|
||||||
groupName: REPORT_GROUP.SALES_REPORT,
|
groupName: REPORT_GROUP.SALES_REPORT,
|
||||||
uniqueName: `${SALES_PREFIX}sales_payment`,
|
uniqueName: `${SALES_PREFIX}sales_payment`,
|
||||||
privilegeKey: 'SALES.REPORT',
|
privilegeKey: 'ADMIN.SALES.REPORT',
|
||||||
label: 'Report Payment',
|
label: 'Report Payment',
|
||||||
tableSchema: `sales_payments main
|
tableSchema: `sales_payments main
|
||||||
JOIN sales_payment_invoices spi ON spi.sales_payment_id = main.id
|
JOIN sales_payment_invoices spi ON spi.sales_payment_id = main.id
|
||||||
@@ -476,7 +476,7 @@ export const salesPaymentReport: ReportConfigEntity = {
|
|||||||
export const visitPlanReport: ReportConfigEntity = {
|
export const visitPlanReport: ReportConfigEntity = {
|
||||||
groupName: REPORT_GROUP.SALES_REPORT,
|
groupName: REPORT_GROUP.SALES_REPORT,
|
||||||
uniqueName: `${SALES_PREFIX}visit_plan`,
|
uniqueName: `${SALES_PREFIX}visit_plan`,
|
||||||
privilegeKey: 'SALES.REPORT',
|
privilegeKey: 'ADMIN.SALES.REPORT',
|
||||||
label: 'Report Visit Plan',
|
label: 'Report Visit Plan',
|
||||||
tableSchema: `plans main
|
tableSchema: `plans main
|
||||||
JOIN employees emp ON emp.id = main.employee_id
|
JOIN employees emp ON emp.id = main.employee_id
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
} from './dto/packing-slip.dto';
|
} from './dto/packing-slip.dto';
|
||||||
import { PackingSlipsService } from './packing-slips.service';
|
import { PackingSlipsService } from './packing-slips.service';
|
||||||
|
|
||||||
export const PACKING_SLIP_PRIVILEGE_KEY = 'SALES.PACKING_SLIP';
|
export const PACKING_SLIP_PRIVILEGE_KEY = 'ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP';
|
||||||
|
|
||||||
@ApiTags('packing-slips')
|
@ApiTags('packing-slips')
|
||||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ import {
|
|||||||
} from './dto/sales-invoice.dto';
|
} from './dto/sales-invoice.dto';
|
||||||
import { SalesInvoicesService } from './sales-invoices.service';
|
import { SalesInvoicesService } from './sales-invoices.service';
|
||||||
|
|
||||||
export const SALES_INVOICE_PRIVILEGE_KEY = 'SALES.INVOICE';
|
export const SALES_INVOICE_PRIVILEGE_KEYS = [
|
||||||
|
'ADMIN.SALES.ACTIVITIES.INVOICE',
|
||||||
|
'MOBILE.SALES.INVOICE',
|
||||||
|
] as const;
|
||||||
|
|
||||||
@ApiTags('sales-invoices')
|
@ApiTags('sales-invoices')
|
||||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||||
@@ -31,7 +34,7 @@ export class SalesInvoicesReadController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@Pagination()
|
@Pagination()
|
||||||
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'view')
|
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEYS, 'view')
|
||||||
@ApiOperation({ summary: 'List sales invoices' })
|
@ApiOperation({ summary: 'List sales invoices' })
|
||||||
@ApiOkResponse({
|
@ApiOkResponse({
|
||||||
schema: {
|
schema: {
|
||||||
@@ -53,7 +56,7 @@ export class SalesInvoicesReadController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'view')
|
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEYS, 'view')
|
||||||
@ApiOperation({ summary: 'Get sales invoice detail' })
|
@ApiOperation({ summary: 'Get sales invoice detail' })
|
||||||
@ApiOkResponse({ type: SalesInvoiceDto })
|
@ApiOkResponse({ type: SalesInvoiceDto })
|
||||||
@ApiNotFoundResponse()
|
@ApiNotFoundResponse()
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
|||||||
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
||||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||||
import { isAllowedCsvUpload } from '../shared/sales-fields';
|
import { isAllowedCsvUpload } from '../shared/sales-fields';
|
||||||
import { SALES_INVOICE_PRIVILEGE_KEY } from './sales-invoices-read.controller';
|
import { SALES_INVOICE_PRIVILEGE_KEYS } from './sales-invoices-read.controller';
|
||||||
import { SalesInvoicesService } from './sales-invoices.service';
|
import { SalesInvoicesService } from './sales-invoices.service';
|
||||||
import {
|
import {
|
||||||
BulkIdsDto,
|
BulkIdsDto,
|
||||||
@@ -47,7 +47,7 @@ export class SalesInvoicesWriteController {
|
|||||||
constructor(private readonly salesInvoicesService: SalesInvoicesService) {}
|
constructor(private readonly salesInvoicesService: SalesInvoicesService) {}
|
||||||
|
|
||||||
@Post('import')
|
@Post('import')
|
||||||
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'import')
|
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEYS, 'import')
|
||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
FileInterceptor('file', {
|
FileInterceptor('file', {
|
||||||
limits: { fileSize: 1_048_576 },
|
limits: { fileSize: 1_048_576 },
|
||||||
@@ -86,7 +86,7 @@ export class SalesInvoicesWriteController {
|
|||||||
|
|
||||||
@Post('bulk-delete')
|
@Post('bulk-delete')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'delete')
|
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEYS, 'delete')
|
||||||
@ApiOperation({ summary: 'Bulk delete sales invoices' })
|
@ApiOperation({ summary: 'Bulk delete sales invoices' })
|
||||||
@ApiOkResponse({
|
@ApiOkResponse({
|
||||||
schema: { properties: { deleted: { type: 'number' } } },
|
schema: { properties: { deleted: { type: 'number' } } },
|
||||||
@@ -99,7 +99,7 @@ export class SalesInvoicesWriteController {
|
|||||||
|
|
||||||
@Post('bulk-status')
|
@Post('bulk-status')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'update')
|
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEYS, 'update')
|
||||||
@ApiOperation({ summary: 'Bulk update sales invoice status' })
|
@ApiOperation({ summary: 'Bulk update sales invoice status' })
|
||||||
@ApiOkResponse({
|
@ApiOkResponse({
|
||||||
schema: { properties: { updated: { type: 'number' } } },
|
schema: { properties: { updated: { type: 'number' } } },
|
||||||
@@ -118,7 +118,7 @@ export class SalesInvoicesWriteController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'create')
|
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEYS, 'create')
|
||||||
@ApiOperation({ summary: 'Create sales invoice' })
|
@ApiOperation({ summary: 'Create sales invoice' })
|
||||||
@ApiCreatedResponse({ type: SalesInvoiceDto })
|
@ApiCreatedResponse({ type: SalesInvoiceDto })
|
||||||
@ApiUnauthorizedResponse()
|
@ApiUnauthorizedResponse()
|
||||||
@@ -147,7 +147,7 @@ export class SalesInvoicesWriteController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/status')
|
@Patch(':id/status')
|
||||||
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'update')
|
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEYS, 'update')
|
||||||
@ApiOperation({ summary: 'Update sales invoice status' })
|
@ApiOperation({ summary: 'Update sales invoice status' })
|
||||||
@ApiOkResponse({ type: SalesInvoiceDto })
|
@ApiOkResponse({ type: SalesInvoiceDto })
|
||||||
@ApiNotFoundResponse()
|
@ApiNotFoundResponse()
|
||||||
@@ -162,7 +162,7 @@ export class SalesInvoicesWriteController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'update')
|
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEYS, 'update')
|
||||||
@ApiOperation({ summary: 'Update sales invoice (not status)' })
|
@ApiOperation({ summary: 'Update sales invoice (not status)' })
|
||||||
@ApiOkResponse({ type: SalesInvoiceDto })
|
@ApiOkResponse({ type: SalesInvoiceDto })
|
||||||
@ApiNotFoundResponse()
|
@ApiNotFoundResponse()
|
||||||
@@ -193,7 +193,7 @@ export class SalesInvoicesWriteController {
|
|||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'delete')
|
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEYS, 'delete')
|
||||||
@ApiOperation({ summary: 'Delete sales invoice' })
|
@ApiOperation({ summary: 'Delete sales invoice' })
|
||||||
@ApiNoContentResponse()
|
@ApiNoContentResponse()
|
||||||
@ApiNotFoundResponse()
|
@ApiNotFoundResponse()
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
|||||||
import { SalesOrderDto, ListSalesOrdersQueryDto } from './dto/sales-order.dto';
|
import { SalesOrderDto, ListSalesOrdersQueryDto } from './dto/sales-order.dto';
|
||||||
import { SalesOrdersService } from './sales-orders.service';
|
import { SalesOrdersService } from './sales-orders.service';
|
||||||
|
|
||||||
export const SALES_ORDER_PRIVILEGE_KEY = 'SALES.ORDER';
|
export const SALES_ORDER_PRIVILEGE_KEYS = [
|
||||||
|
'ADMIN.SALES.ACTIVITIES.ORDER',
|
||||||
|
'MOBILE.SALES.ORDER',
|
||||||
|
] as const;
|
||||||
|
|
||||||
@ApiTags('sales-orders')
|
@ApiTags('sales-orders')
|
||||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||||
@@ -28,7 +31,7 @@ export class SalesOrdersReadController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@Pagination()
|
@Pagination()
|
||||||
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'view')
|
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEYS, 'view')
|
||||||
@ApiOperation({ summary: 'List sales orders' })
|
@ApiOperation({ summary: 'List sales orders' })
|
||||||
@ApiOkResponse({
|
@ApiOkResponse({
|
||||||
schema: {
|
schema: {
|
||||||
@@ -50,7 +53,7 @@ export class SalesOrdersReadController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'view')
|
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEYS, 'view')
|
||||||
@ApiOperation({ summary: 'Get sales order detail' })
|
@ApiOperation({ summary: 'Get sales order detail' })
|
||||||
@ApiOkResponse({ type: SalesOrderDto })
|
@ApiOkResponse({ type: SalesOrderDto })
|
||||||
@ApiNotFoundResponse()
|
@ApiNotFoundResponse()
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
|||||||
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
||||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||||
import { isAllowedCsvUpload } from '../shared/sales-fields';
|
import { isAllowedCsvUpload } from '../shared/sales-fields';
|
||||||
import { SALES_ORDER_PRIVILEGE_KEY } from './sales-orders-read.controller';
|
import { SALES_ORDER_PRIVILEGE_KEYS } from './sales-orders-read.controller';
|
||||||
import { SalesOrdersService } from './sales-orders.service';
|
import { SalesOrdersService } from './sales-orders.service';
|
||||||
import {
|
import {
|
||||||
BulkIdsDto,
|
BulkIdsDto,
|
||||||
@@ -47,7 +47,7 @@ export class SalesOrdersWriteController {
|
|||||||
constructor(private readonly salesOrdersService: SalesOrdersService) {}
|
constructor(private readonly salesOrdersService: SalesOrdersService) {}
|
||||||
|
|
||||||
@Post('import')
|
@Post('import')
|
||||||
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'import')
|
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEYS, 'import')
|
||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
FileInterceptor('file', {
|
FileInterceptor('file', {
|
||||||
limits: { fileSize: 1_048_576 },
|
limits: { fileSize: 1_048_576 },
|
||||||
@@ -86,7 +86,7 @@ export class SalesOrdersWriteController {
|
|||||||
|
|
||||||
@Post('bulk-delete')
|
@Post('bulk-delete')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'delete')
|
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEYS, 'delete')
|
||||||
@ApiOperation({ summary: 'Bulk delete sales orders' })
|
@ApiOperation({ summary: 'Bulk delete sales orders' })
|
||||||
@ApiOkResponse({
|
@ApiOkResponse({
|
||||||
schema: { properties: { deleted: { type: 'number' } } },
|
schema: { properties: { deleted: { type: 'number' } } },
|
||||||
@@ -99,7 +99,7 @@ export class SalesOrdersWriteController {
|
|||||||
|
|
||||||
@Post('bulk-status')
|
@Post('bulk-status')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'update')
|
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEYS, 'update')
|
||||||
@ApiOperation({ summary: 'Bulk update sales order status' })
|
@ApiOperation({ summary: 'Bulk update sales order status' })
|
||||||
@ApiOkResponse({
|
@ApiOkResponse({
|
||||||
schema: { properties: { updated: { type: 'number' } } },
|
schema: { properties: { updated: { type: 'number' } } },
|
||||||
@@ -119,7 +119,7 @@ export class SalesOrdersWriteController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'create')
|
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEYS, 'create')
|
||||||
@ApiOperation({ summary: 'Create sales order' })
|
@ApiOperation({ summary: 'Create sales order' })
|
||||||
@ApiCreatedResponse({ type: SalesOrderDto })
|
@ApiCreatedResponse({ type: SalesOrderDto })
|
||||||
@ApiUnauthorizedResponse()
|
@ApiUnauthorizedResponse()
|
||||||
@@ -148,7 +148,7 @@ export class SalesOrdersWriteController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/status')
|
@Patch(':id/status')
|
||||||
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'update')
|
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEYS, 'update')
|
||||||
@ApiOperation({ summary: 'Update sales order status' })
|
@ApiOperation({ summary: 'Update sales order status' })
|
||||||
@ApiOkResponse({ type: SalesOrderDto })
|
@ApiOkResponse({ type: SalesOrderDto })
|
||||||
@ApiNotFoundResponse()
|
@ApiNotFoundResponse()
|
||||||
@@ -165,7 +165,7 @@ export class SalesOrdersWriteController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'update')
|
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEYS, 'update')
|
||||||
@ApiOperation({ summary: 'Update sales order (not status)' })
|
@ApiOperation({ summary: 'Update sales order (not status)' })
|
||||||
@ApiOkResponse({ type: SalesOrderDto })
|
@ApiOkResponse({ type: SalesOrderDto })
|
||||||
@ApiNotFoundResponse()
|
@ApiNotFoundResponse()
|
||||||
@@ -195,7 +195,7 @@ export class SalesOrdersWriteController {
|
|||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'delete')
|
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEYS, 'delete')
|
||||||
@ApiOperation({ summary: 'Delete sales order' })
|
@ApiOperation({ summary: 'Delete sales order' })
|
||||||
@ApiNoContentResponse()
|
@ApiNoContentResponse()
|
||||||
@ApiNotFoundResponse()
|
@ApiNotFoundResponse()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { CustomersModule } from '../../configuration/customers/customers.module'
|
|||||||
import { DivisionsModule } from '../../configuration/divisions/divisions.module';
|
import { DivisionsModule } from '../../configuration/divisions/divisions.module';
|
||||||
import { EmployeesModule } from '../../configuration/employees/employees.module';
|
import { EmployeesModule } from '../../configuration/employees/employees.module';
|
||||||
import { ProductsModule } from '../../configuration/products/products.module';
|
import { ProductsModule } from '../../configuration/products/products.module';
|
||||||
|
import { TimelineModule } from '../../field/timeline/timeline.module';
|
||||||
import { SalesRequestsModule } from '../sales-requests/sales-requests.module';
|
import { SalesRequestsModule } from '../sales-requests/sales-requests.module';
|
||||||
import { DocumentCodeService } from '../shared/document-code.service';
|
import { DocumentCodeService } from '../shared/document-code.service';
|
||||||
import { SalesDocumentFlowModule } from '../shared/sales-document-flow.module';
|
import { SalesDocumentFlowModule } from '../shared/sales-document-flow.module';
|
||||||
@@ -20,6 +21,7 @@ import { SalesOrdersService } from './sales-orders.service';
|
|||||||
CustomersModule,
|
CustomersModule,
|
||||||
ProductsModule,
|
ProductsModule,
|
||||||
SalesRequestsModule,
|
SalesRequestsModule,
|
||||||
|
TimelineModule,
|
||||||
forwardRef(() => SalesDocumentFlowModule),
|
forwardRef(() => SalesDocumentFlowModule),
|
||||||
],
|
],
|
||||||
controllers: [SalesOrdersReadController, SalesOrdersWriteController],
|
controllers: [SalesOrdersReadController, SalesOrdersWriteController],
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { CustomersService } from '../../configuration/customers/customers.servic
|
|||||||
import { DivisionsService } from '../../configuration/divisions/divisions.service';
|
import { DivisionsService } from '../../configuration/divisions/divisions.service';
|
||||||
import { EmployeesService } from '../../configuration/employees/employees.service';
|
import { EmployeesService } from '../../configuration/employees/employees.service';
|
||||||
import { ProductsService } from '../../configuration/products/products.service';
|
import { ProductsService } from '../../configuration/products/products.service';
|
||||||
|
import { TimelineActivitiesService } from '../../field/timeline/timeline-activities.service';
|
||||||
import { SalesRequestsService } from '../sales-requests/sales-requests.service';
|
import { SalesRequestsService } from '../sales-requests/sales-requests.service';
|
||||||
import {
|
import {
|
||||||
isValidDocumentAddress,
|
isValidDocumentAddress,
|
||||||
@@ -95,6 +96,7 @@ export class SalesOrdersService {
|
|||||||
private readonly salesRequestsService: SalesRequestsService,
|
private readonly salesRequestsService: SalesRequestsService,
|
||||||
@Inject(forwardRef(() => SalesDocumentFlowService))
|
@Inject(forwardRef(() => SalesDocumentFlowService))
|
||||||
private readonly salesDocumentFlowService: SalesDocumentFlowService,
|
private readonly salesDocumentFlowService: SalesDocumentFlowService,
|
||||||
|
private readonly timelineActivitiesService: TimelineActivitiesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(
|
async list(
|
||||||
@@ -152,6 +154,14 @@ export class SalesOrdersService {
|
|||||||
const created = await this.salesOrdersRepository.create(
|
const created = await this.salesOrdersRepository.create(
|
||||||
await this.toCreateInput(merged),
|
await this.toCreateInput(merged),
|
||||||
);
|
);
|
||||||
|
await this.timelineActivitiesService.recordIfLocated({
|
||||||
|
employeeId: created.salesPersonId,
|
||||||
|
type: 'sales_order_created',
|
||||||
|
sourceType: 'sales-order',
|
||||||
|
sourceId: created.id,
|
||||||
|
latitude: created.latitude,
|
||||||
|
longitude: created.longitude,
|
||||||
|
});
|
||||||
return this.toDetail(created);
|
return this.toDetail(created);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,18 @@ import {
|
|||||||
IsArray,
|
IsArray,
|
||||||
IsIn,
|
IsIn,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
Matches,
|
Matches,
|
||||||
|
Max,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
|
Min,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
|
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
import {
|
import {
|
||||||
PaginationQueryDto,
|
PaginationQueryDto,
|
||||||
UserRelationDto,
|
UserRelationDto,
|
||||||
@@ -87,6 +92,20 @@ export class CreateSalesPaymentDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn([...SALES_PAYMENT_STATUSES])
|
@IsIn([...SALES_PAYMENT_STATUSES])
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: -6.2 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(-90)
|
||||||
|
@Max(90)
|
||||||
|
latitude?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 106.8 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(-180)
|
||||||
|
@Max(180)
|
||||||
|
longitude?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateSalesPaymentDto {
|
export class UpdateSalesPaymentDto {
|
||||||
@@ -163,6 +182,17 @@ export class ListSalesPaymentsQueryDto extends PaginationQueryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
search?: string;
|
search?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '2026-09-01' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(DATE_PATTERN)
|
||||||
|
date?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
createdBy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SalesPaymentDto {
|
export class SalesPaymentDto {
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ export type ListSalesPaymentsFilters = {
|
|||||||
readonly code?: string;
|
readonly code?: string;
|
||||||
readonly status?: string;
|
readonly status?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
readonly date?: number;
|
||||||
|
readonly createdBy?: string;
|
||||||
readonly orderBy?: string;
|
readonly orderBy?: string;
|
||||||
readonly orderType?: string;
|
readonly orderType?: string;
|
||||||
readonly limit: number;
|
readonly limit: number;
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ import {
|
|||||||
} from './dto/sales-payment.dto';
|
} from './dto/sales-payment.dto';
|
||||||
import { SalesPaymentsService } from './sales-payments.service';
|
import { SalesPaymentsService } from './sales-payments.service';
|
||||||
|
|
||||||
export const SALES_PAYMENT_PRIVILEGE_KEY = 'SALES.PAYMENT';
|
export const SALES_PAYMENT_PRIVILEGE_KEYS = [
|
||||||
|
'ADMIN.SALES.ACTIVITIES.PAYMENT',
|
||||||
|
'MOBILE.SALES.PAYMENT',
|
||||||
|
] as const;
|
||||||
|
|
||||||
@ApiTags('sales-payments')
|
@ApiTags('sales-payments')
|
||||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||||
@@ -31,7 +34,7 @@ export class SalesPaymentsReadController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@Pagination()
|
@Pagination()
|
||||||
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'view')
|
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEYS, 'view')
|
||||||
@ApiOperation({ summary: 'List sales payments' })
|
@ApiOperation({ summary: 'List sales payments' })
|
||||||
@ApiOkResponse({
|
@ApiOkResponse({
|
||||||
schema: {
|
schema: {
|
||||||
@@ -53,7 +56,7 @@ export class SalesPaymentsReadController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'view')
|
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEYS, 'view')
|
||||||
@ApiOperation({ summary: 'Get sales payment detail' })
|
@ApiOperation({ summary: 'Get sales payment detail' })
|
||||||
@ApiOkResponse({ type: SalesPaymentDto })
|
@ApiOkResponse({ type: SalesPaymentDto })
|
||||||
@ApiNotFoundResponse()
|
@ApiNotFoundResponse()
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
|||||||
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
||||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||||
import { isAllowedCsvUpload } from '../shared/sales-fields';
|
import { isAllowedCsvUpload } from '../shared/sales-fields';
|
||||||
import { SALES_PAYMENT_PRIVILEGE_KEY } from './sales-payments-read.controller';
|
import { SALES_PAYMENT_PRIVILEGE_KEYS } from './sales-payments-read.controller';
|
||||||
import { SalesPaymentsService } from './sales-payments.service';
|
import { SalesPaymentsService } from './sales-payments.service';
|
||||||
import {
|
import {
|
||||||
BulkIdsDto,
|
BulkIdsDto,
|
||||||
@@ -47,7 +47,7 @@ export class SalesPaymentsWriteController {
|
|||||||
constructor(private readonly salesPaymentsService: SalesPaymentsService) {}
|
constructor(private readonly salesPaymentsService: SalesPaymentsService) {}
|
||||||
|
|
||||||
@Post('import')
|
@Post('import')
|
||||||
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'import')
|
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEYS, 'import')
|
||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
FileInterceptor('file', {
|
FileInterceptor('file', {
|
||||||
limits: { fileSize: 1_048_576 },
|
limits: { fileSize: 1_048_576 },
|
||||||
@@ -86,7 +86,7 @@ export class SalesPaymentsWriteController {
|
|||||||
|
|
||||||
@Post('bulk-delete')
|
@Post('bulk-delete')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'delete')
|
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEYS, 'delete')
|
||||||
@ApiOperation({ summary: 'Bulk delete sales payments' })
|
@ApiOperation({ summary: 'Bulk delete sales payments' })
|
||||||
@ApiOkResponse({
|
@ApiOkResponse({
|
||||||
schema: { properties: { deleted: { type: 'number' } } },
|
schema: { properties: { deleted: { type: 'number' } } },
|
||||||
@@ -99,7 +99,7 @@ export class SalesPaymentsWriteController {
|
|||||||
|
|
||||||
@Post('bulk-status')
|
@Post('bulk-status')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'update')
|
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEYS, 'update')
|
||||||
@ApiOperation({ summary: 'Bulk update sales payment status' })
|
@ApiOperation({ summary: 'Bulk update sales payment status' })
|
||||||
@ApiOkResponse({
|
@ApiOkResponse({
|
||||||
schema: { properties: { updated: { type: 'number' } } },
|
schema: { properties: { updated: { type: 'number' } } },
|
||||||
@@ -118,7 +118,7 @@ export class SalesPaymentsWriteController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'create')
|
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEYS, 'create')
|
||||||
@ApiOperation({ summary: 'Create sales payment' })
|
@ApiOperation({ summary: 'Create sales payment' })
|
||||||
@ApiCreatedResponse({ type: SalesPaymentDto })
|
@ApiCreatedResponse({ type: SalesPaymentDto })
|
||||||
@ApiUnauthorizedResponse()
|
@ApiUnauthorizedResponse()
|
||||||
@@ -134,12 +134,14 @@ export class SalesPaymentsWriteController {
|
|||||||
invoices: dto.invoices,
|
invoices: dto.invoices,
|
||||||
images: dto.images,
|
images: dto.images,
|
||||||
status: dto.status,
|
status: dto.status,
|
||||||
|
latitude: dto.latitude,
|
||||||
|
longitude: dto.longitude,
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/status')
|
@Patch(':id/status')
|
||||||
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'update')
|
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEYS, 'update')
|
||||||
@ApiOperation({ summary: 'Update sales payment status' })
|
@ApiOperation({ summary: 'Update sales payment status' })
|
||||||
@ApiOkResponse({ type: SalesPaymentDto })
|
@ApiOkResponse({ type: SalesPaymentDto })
|
||||||
@ApiNotFoundResponse()
|
@ApiNotFoundResponse()
|
||||||
@@ -154,7 +156,7 @@ export class SalesPaymentsWriteController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'update')
|
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEYS, 'update')
|
||||||
@ApiOperation({ summary: 'Update sales payment (not status)' })
|
@ApiOperation({ summary: 'Update sales payment (not status)' })
|
||||||
@ApiOkResponse({ type: SalesPaymentDto })
|
@ApiOkResponse({ type: SalesPaymentDto })
|
||||||
@ApiNotFoundResponse()
|
@ApiNotFoundResponse()
|
||||||
@@ -177,7 +179,7 @@ export class SalesPaymentsWriteController {
|
|||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'delete')
|
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEYS, 'delete')
|
||||||
@ApiOperation({ summary: 'Delete sales payment' })
|
@ApiOperation({ summary: 'Delete sales payment' })
|
||||||
@ApiNoContentResponse()
|
@ApiNoContentResponse()
|
||||||
@ApiNotFoundResponse()
|
@ApiNotFoundResponse()
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { EmployeesModule } from '../../configuration/employees/employees.module';
|
||||||
|
import { TimelineModule } from '../../field/timeline/timeline.module';
|
||||||
import { SalesInvoicesModule } from '../sales-invoices/sales-invoices.module';
|
import { SalesInvoicesModule } from '../sales-invoices/sales-invoices.module';
|
||||||
import { DocumentCodeService } from '../shared/document-code.service';
|
import { DocumentCodeService } from '../shared/document-code.service';
|
||||||
import { SalesPaymentsReadController } from './sales-payments-read.controller';
|
import { SalesPaymentsReadController } from './sales-payments-read.controller';
|
||||||
@@ -7,7 +9,7 @@ import { SalesPaymentsRepository } from './sales-payments.repository';
|
|||||||
import { SalesPaymentsService } from './sales-payments.service';
|
import { SalesPaymentsService } from './sales-payments.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [SalesInvoicesModule],
|
imports: [SalesInvoicesModule, EmployeesModule, TimelineModule],
|
||||||
controllers: [SalesPaymentsReadController, SalesPaymentsWriteController],
|
controllers: [SalesPaymentsReadController, SalesPaymentsWriteController],
|
||||||
providers: [
|
providers: [
|
||||||
DocumentCodeService,
|
DocumentCodeService,
|
||||||
|
|||||||
@@ -316,6 +316,12 @@ export class SalesPaymentsRepository {
|
|||||||
parts.push(search);
|
parts.push(search);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (filters.date !== undefined) {
|
||||||
|
parts.push(eq(salesPayments.date, filters.date));
|
||||||
|
}
|
||||||
|
if (filters.createdBy) {
|
||||||
|
parts.push(eq(salesPayments.createdBy, filters.createdBy));
|
||||||
|
}
|
||||||
if (parts.length === 0) {
|
if (parts.length === 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,6 +141,24 @@ describe('SalesPaymentsService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('list forwards date and createdBy filters to the repository', async () => {
|
||||||
|
repository.list.mockResolvedValue({ data: [], total: 0 });
|
||||||
|
await service.list({
|
||||||
|
date: '2026-09-01',
|
||||||
|
createdBy: 'user-1',
|
||||||
|
page: 1,
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
expect(repository.list).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
date: DateTime.create('2026-09-01').startOfDay().value,
|
||||||
|
createdBy: 'user-1',
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('approving a payment recomputes referenced invoices', async () => {
|
it('approving a payment recomputes referenced invoices', async () => {
|
||||||
repository.findById.mockResolvedValue({
|
repository.findById.mockResolvedValue({
|
||||||
...sample,
|
...sample,
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
|||||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||||
import { InvalidDecimalError } from '../../../common/value-objects/decimal/invalid-decimal.error';
|
import { InvalidDecimalError } from '../../../common/value-objects/decimal/invalid-decimal.error';
|
||||||
import { Status } from '../../../common/value-objects/status/status';
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
|
import { EmployeesService } from '../../configuration/employees/employees.service';
|
||||||
|
import { TimelineActivitiesService } from '../../field/timeline/timeline-activities.service';
|
||||||
import { SalesInvoicesService } from '../sales-invoices/sales-invoices.service';
|
import { SalesInvoicesService } from '../sales-invoices/sales-invoices.service';
|
||||||
import {
|
import {
|
||||||
isValidDocumentCode,
|
isValidDocumentCode,
|
||||||
@@ -49,6 +51,8 @@ export type ListSalesPaymentsQuery = {
|
|||||||
readonly code?: string;
|
readonly code?: string;
|
||||||
readonly status?: string;
|
readonly status?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
readonly date?: string;
|
||||||
|
readonly createdBy?: string;
|
||||||
readonly orderBy?: string;
|
readonly orderBy?: string;
|
||||||
readonly orderType?: string;
|
readonly orderType?: string;
|
||||||
readonly page?: number;
|
readonly page?: number;
|
||||||
@@ -63,6 +67,8 @@ export class SalesPaymentsService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly salesPaymentsRepository: SalesPaymentsRepository,
|
private readonly salesPaymentsRepository: SalesPaymentsRepository,
|
||||||
private readonly salesInvoicesService: SalesInvoicesService,
|
private readonly salesInvoicesService: SalesInvoicesService,
|
||||||
|
private readonly employeesService: EmployeesService,
|
||||||
|
private readonly timelineActivitiesService: TimelineActivitiesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(
|
async list(
|
||||||
@@ -75,6 +81,10 @@ export class SalesPaymentsService {
|
|||||||
code: query.code,
|
code: query.code,
|
||||||
status: query.status,
|
status: query.status,
|
||||||
search: query.search,
|
search: query.search,
|
||||||
|
date: query.date
|
||||||
|
? this.assertDate(query.date).startOfDay().value
|
||||||
|
: undefined,
|
||||||
|
createdBy: query.createdBy,
|
||||||
orderBy: query.orderBy,
|
orderBy: query.orderBy,
|
||||||
orderType: query.orderType,
|
orderType: query.orderType,
|
||||||
limit: page.limit,
|
limit: page.limit,
|
||||||
@@ -103,11 +113,22 @@ export class SalesPaymentsService {
|
|||||||
invoices: PaymentAllocationBody[];
|
invoices: PaymentAllocationBody[];
|
||||||
images?: SalesImageBody[];
|
images?: SalesImageBody[];
|
||||||
status?: string;
|
status?: string;
|
||||||
|
latitude?: number | null;
|
||||||
|
longitude?: number | null;
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<ReturnType<SalesPaymentsService['toDetail']>> {
|
}): Promise<ReturnType<SalesPaymentsService['toDetail']>> {
|
||||||
const created = await this.salesPaymentsRepository.create(
|
const created = await this.salesPaymentsRepository.create(
|
||||||
await this.toCreateInput(input),
|
await this.toCreateInput(input),
|
||||||
);
|
);
|
||||||
|
const employee = await this.employeesService.requireByUserId(input.userId);
|
||||||
|
await this.timelineActivitiesService.recordIfLocated({
|
||||||
|
employeeId: employee.id,
|
||||||
|
type: 'sales_payment_created',
|
||||||
|
sourceType: 'sales-payment',
|
||||||
|
sourceId: created.id,
|
||||||
|
latitude: input.latitude,
|
||||||
|
longitude: input.longitude,
|
||||||
|
});
|
||||||
return this.toDetail(created);
|
return this.toDetail(created);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ import {
|
|||||||
} from './dto/sales-request.dto';
|
} from './dto/sales-request.dto';
|
||||||
import { SalesRequestsService } from './sales-requests.service';
|
import { SalesRequestsService } from './sales-requests.service';
|
||||||
|
|
||||||
export const SALES_REQUEST_PRIVILEGE_KEY = 'SALES.REQUEST';
|
export const SALES_REQUEST_PRIVILEGE_KEYS = [
|
||||||
|
'ADMIN.SALES.ACTIVITIES.REQUEST',
|
||||||
|
'MOBILE.SALES.REQUEST',
|
||||||
|
] as const;
|
||||||
|
|
||||||
@ApiTags('sales-requests')
|
@ApiTags('sales-requests')
|
||||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||||
@@ -31,7 +34,7 @@ export class SalesRequestsReadController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@Pagination()
|
@Pagination()
|
||||||
@RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEY, 'view')
|
@RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEYS, 'view')
|
||||||
@ApiOperation({ summary: 'List sales requests' })
|
@ApiOperation({ summary: 'List sales requests' })
|
||||||
@ApiOkResponse({
|
@ApiOkResponse({
|
||||||
schema: {
|
schema: {
|
||||||
@@ -53,7 +56,7 @@ export class SalesRequestsReadController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEY, 'view')
|
@RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEYS, 'view')
|
||||||
@ApiOperation({ summary: 'Get sales request detail' })
|
@ApiOperation({ summary: 'Get sales request detail' })
|
||||||
@ApiOkResponse({ type: SalesRequestDto })
|
@ApiOkResponse({ type: SalesRequestDto })
|
||||||
@ApiNotFoundResponse()
|
@ApiNotFoundResponse()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user