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

Some files were not shown because too many files have changed in this diff Show More