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.
This commit is contained in:
@@ -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,37 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { bigint, index, pgTable, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
|
||||
import { branches } from './branches-table';
|
||||
import { checkInColumns, checkOutColumns } from './checkpoint-columns';
|
||||
import { employees } from './employees-table';
|
||||
import { primaryEntityColumns } from './primary-entity-columns';
|
||||
import { users } from './schema';
|
||||
|
||||
export const attendances = pgTable(
|
||||
'attendances',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
employeeId: uuid('employee_id')
|
||||
.notNull()
|
||||
.references(() => employees.id, { onDelete: 'restrict' }),
|
||||
branchId: uuid('branch_id')
|
||||
.notNull()
|
||||
.references(() => branches.id, { onDelete: 'restrict' }),
|
||||
date: bigint('date', { mode: 'number' }).notNull(),
|
||||
...checkInColumns,
|
||||
...checkOutColumns,
|
||||
...primaryEntityColumns(users),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('attendances_employee_date_live_unique')
|
||||
.on(t.employeeId, t.date)
|
||||
.where(sql`${t.status} <> 'archived'`),
|
||||
uniqueIndex('attendances_employee_open_unique')
|
||||
.on(t.employeeId)
|
||||
.where(sql`${t.checkOutAt} IS NULL AND ${t.status} <> 'archived'`),
|
||||
index('attendances_employee_id_idx').on(t.employeeId),
|
||||
index('attendances_branch_id_idx').on(t.branchId),
|
||||
],
|
||||
);
|
||||
|
||||
export type AttendanceRow = typeof attendances.$inferSelect;
|
||||
export type NewAttendanceRow = typeof attendances.$inferInsert;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { bigint, doublePrecision, integer, text } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const checkInColumns = {
|
||||
checkInAt: bigint('check_in_at', { mode: 'number' }).notNull(),
|
||||
checkInMethod: text('check_in_method').notNull(),
|
||||
checkInLatitude: doublePrecision('check_in_latitude').notNull(),
|
||||
checkInLongitude: doublePrecision('check_in_longitude').notNull(),
|
||||
checkInPhotoUrl: text('check_in_photo_url'),
|
||||
checkInDistanceMeters: integer('check_in_distance_meters'),
|
||||
};
|
||||
|
||||
export const checkOutColumns = {
|
||||
checkOutAt: bigint('check_out_at', { mode: 'number' }),
|
||||
checkOutMethod: text('check_out_method'),
|
||||
checkOutLatitude: doublePrecision('check_out_latitude'),
|
||||
checkOutLongitude: doublePrecision('check_out_longitude'),
|
||||
checkOutPhotoUrl: text('check_out_photo_url'),
|
||||
checkOutDistanceMeters: integer('check_out_distance_meters'),
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { bigint, pgTable, uuid } from 'drizzle-orm/pg-core';
|
||||
import { bigint, integer, pgTable, uuid } from 'drizzle-orm/pg-core';
|
||||
import { primaryEntityColumns } from './primary-entity-columns';
|
||||
import { users } from './schema';
|
||||
|
||||
@@ -8,6 +8,7 @@ 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),
|
||||
...primaryEntityColumns(users),
|
||||
});
|
||||
|
||||
|
||||
@@ -247,6 +247,12 @@ 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 {
|
||||
reportBookmarks,
|
||||
type NewReportBookmarkRow,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { bigint, index, pgTable, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
|
||||
import { attendances } from './attendances-table';
|
||||
import { checkInColumns, checkOutColumns } from './checkpoint-columns';
|
||||
import { customers } from './customers-table';
|
||||
import { employees } from './employees-table';
|
||||
import { planDestinations, plans } from './plans-table';
|
||||
import { primaryEntityColumns } from './primary-entity-columns';
|
||||
import { users } from './schema';
|
||||
|
||||
export const visits = pgTable(
|
||||
'visits',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
employeeId: uuid('employee_id')
|
||||
.notNull()
|
||||
.references(() => employees.id, { onDelete: 'restrict' }),
|
||||
customerId: uuid('customer_id')
|
||||
.notNull()
|
||||
.references(() => customers.id, { onDelete: 'restrict' }),
|
||||
attendanceId: uuid('attendance_id').references(() => attendances.id, {
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
planId: uuid('plan_id').references(() => plans.id, {
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
planDestinationId: uuid('plan_destination_id').references(
|
||||
() => planDestinations.id,
|
||||
{ onDelete: 'set null' },
|
||||
),
|
||||
date: bigint('date', { mode: 'number' }).notNull(),
|
||||
...checkInColumns,
|
||||
...checkOutColumns,
|
||||
...primaryEntityColumns(users),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('visits_employee_open_unique')
|
||||
.on(t.employeeId)
|
||||
.where(sql`${t.checkOutAt} IS NULL AND ${t.status} <> 'archived'`),
|
||||
index('visits_employee_id_idx').on(t.employeeId),
|
||||
index('visits_customer_id_idx').on(t.customerId),
|
||||
index('visits_attendance_id_idx').on(t.attendanceId),
|
||||
],
|
||||
);
|
||||
|
||||
export type VisitRow = typeof visits.$inferSelect;
|
||||
export type NewVisitRow = typeof visits.$inferInsert;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { PrivilegesService } from '../privileges/privileges.service';
|
||||
import { EmployeesService } from '../configuration/employees/employees.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
@@ -13,6 +14,9 @@ describe('AuthController', () => {
|
||||
let privilegesService: jest.Mocked<
|
||||
Pick<PrivilegesService, 'findPrivilegeSummary' | 'getPermissionsMap'>
|
||||
>;
|
||||
let employeesService: jest.Mocked<
|
||||
Pick<EmployeesService, 'findRelationByUserId'>
|
||||
>;
|
||||
|
||||
beforeEach(async () => {
|
||||
authService = {
|
||||
@@ -43,6 +47,9 @@ describe('AuthController', () => {
|
||||
findPrivilegeSummary: jest.fn(),
|
||||
getPermissionsMap: jest.fn(),
|
||||
};
|
||||
employeesService = {
|
||||
findRelationByUserId: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AuthController],
|
||||
@@ -50,6 +57,7 @@ describe('AuthController', () => {
|
||||
{ provide: AuthService, useValue: authService },
|
||||
{ provide: UsersService, useValue: usersService },
|
||||
{ provide: PrivilegesService, useValue: privilegesService },
|
||||
{ provide: EmployeesService, useValue: employeesService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -91,6 +99,7 @@ describe('AuthController', () => {
|
||||
username: 'alice',
|
||||
isSuperadmin: false,
|
||||
privilege: null,
|
||||
employee: null,
|
||||
permissions: {},
|
||||
});
|
||||
});
|
||||
@@ -153,6 +162,7 @@ describe('AuthController', () => {
|
||||
username: 'alice',
|
||||
isSuperadmin: true,
|
||||
privilege: null,
|
||||
employee: null,
|
||||
permissions: {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
import { BEARER_AUTH_NAME } from '../../common/swagger/setup-swagger';
|
||||
import { PrivilegesService } from '../privileges/privileges.service';
|
||||
import { EmployeesService } from '../configuration/employees/employees.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import {
|
||||
@@ -35,6 +36,7 @@ export class AuthController {
|
||||
private readonly authService: AuthService,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly privilegesService: PrivilegesService,
|
||||
private readonly employeesService: EmployeesService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@@ -97,12 +99,15 @@ export class AuthController {
|
||||
async me(@CurrentUser() user: AuthUser): Promise<MeResponseDto> {
|
||||
const full = await this.usersService.findById(user.id);
|
||||
const isSuperadmin = full?.isSuperadmin ?? user.isSuperadmin;
|
||||
const employee = await this.employeesService.findRelationByUserId(user.id);
|
||||
|
||||
if (!full?.privilegeId) {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
isSuperadmin,
|
||||
privilege: null,
|
||||
employee,
|
||||
permissions: {},
|
||||
};
|
||||
}
|
||||
@@ -119,6 +124,7 @@ export class AuthController {
|
||||
username: user.username,
|
||||
isSuperadmin,
|
||||
privilege,
|
||||
employee,
|
||||
permissions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { PrivilegesGuard } from '../../common/guards/privileges.guard';
|
||||
import { PrivilegesModule } from '../privileges/privileges.module';
|
||||
import { EmployeesModule } from '../configuration/employees/employees.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
@@ -17,6 +18,7 @@ import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
EmployeesModule,
|
||||
PrivilegesModule,
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
|
||||
@@ -90,6 +90,20 @@ export class MePrivilegeDto {
|
||||
code!: string;
|
||||
}
|
||||
|
||||
export class MeEmployeeDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
code!: string;
|
||||
|
||||
@ApiProperty()
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: 'sales' })
|
||||
position!: string;
|
||||
}
|
||||
|
||||
export class MeResponseDto {
|
||||
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
|
||||
id!: string;
|
||||
@@ -103,6 +117,9 @@ 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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,262 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
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,
|
||||
} from '../shared/check-in-verification';
|
||||
import { FIELD_ATTENDANCE_PRIVILEGE_KEY } from '../shared/field-purpose';
|
||||
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,
|
||||
) {}
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
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,
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
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 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;
|
||||
}
|
||||
@@ -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,6 +20,10 @@ 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';
|
||||
|
||||
@Module({
|
||||
@@ -29,20 +37,34 @@ import { FieldPrivilegeGuard } from './shared/field-privilege.guard';
|
||||
],
|
||||
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,
|
||||
],
|
||||
exports: [CompanySettingsService, CyclesService, PlansService],
|
||||
})
|
||||
export class FieldModule {}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Status } from '../../../common/value-objects/status/status';
|
||||
export type CompanySetting = {
|
||||
readonly id: string;
|
||||
readonly cycleStartDate: DateTime;
|
||||
readonly checkInRadiusMeters: number;
|
||||
readonly status: Status;
|
||||
readonly createdAt: DateTime;
|
||||
readonly updatedAt: DateTime;
|
||||
@@ -13,5 +14,6 @@ export type CompanySetting = {
|
||||
|
||||
export type UpsertCompanySettingInput = {
|
||||
readonly cycleStartDate: DateTime;
|
||||
readonly checkInRadiusMeters?: 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,7 @@ export class CompanySettingsRepository {
|
||||
.insert(companySettings)
|
||||
.values({
|
||||
cycleStartDate: input.cycleStartDate.value,
|
||||
checkInRadiusMeters: input.checkInRadiusMeters ?? 100,
|
||||
status: Status.create(Status.DEFAULT).value,
|
||||
createdAt: now.value,
|
||||
updatedAt: now.value,
|
||||
@@ -46,6 +47,9 @@ export class CompanySettingsRepository {
|
||||
.update(companySettings)
|
||||
.set({
|
||||
cycleStartDate: input.cycleStartDate.value,
|
||||
...(input.checkInRadiusMeters !== undefined
|
||||
? { checkInRadiusMeters: input.checkInRadiusMeters }
|
||||
: {}),
|
||||
updatedAt: now.value,
|
||||
updatedBy: input.userId,
|
||||
})
|
||||
@@ -58,6 +62,7 @@ export class CompanySettingsRepository {
|
||||
return {
|
||||
id: row.id,
|
||||
cycleStartDate: DateTime.fromUnixMs(row.cycleStartDate),
|
||||
checkInRadiusMeters: row.checkInRadiusMeters,
|
||||
status: Status.create(row.status),
|
||||
createdAt: DateTime.fromUnixMs(row.createdAt),
|
||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||
|
||||
@@ -16,6 +16,7 @@ describe('CompanySettingsService', () => {
|
||||
const sample: CompanySetting = {
|
||||
id: 'set-1',
|
||||
cycleStartDate: DateTime.create('2026-01-05'),
|
||||
checkInRadiusMeters: 100,
|
||||
status: Status.create('draft'),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -46,19 +47,29 @@ describe('CompanySettingsService', () => {
|
||||
repository.find.mockResolvedValue(sample);
|
||||
const result = await service.get();
|
||||
expect(result.cycleStartDate).toBe(sample.cycleStartDate.value);
|
||||
expect(result.checkInRadiusMeters).toBe(100);
|
||||
});
|
||||
|
||||
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 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,41 @@ export class CompanySettingsService {
|
||||
}
|
||||
|
||||
async update(
|
||||
cycleStartDateRaw: string,
|
||||
input: {
|
||||
cycleStartDate?: string;
|
||||
checkInRadiusMeters?: 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');
|
||||
}
|
||||
const saved = await this.companySettingsRepository.upsert({
|
||||
cycleStartDate,
|
||||
checkInRadiusMeters: input.checkInRadiusMeters,
|
||||
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) {
|
||||
@@ -46,6 +70,7 @@ export class CompanySettingsService {
|
||||
return {
|
||||
id: setting.id,
|
||||
cycleStartDate: setting.cycleStartDate.value,
|
||||
checkInRadiusMeters: setting.checkInRadiusMeters,
|
||||
status: setting.status.value,
|
||||
createdAt: setting.createdAt.value,
|
||||
updatedAt: setting.updatedAt.value,
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export class CompanySettingDto {
|
||||
@@ -18,6 +34,9 @@ export class CompanySettingDto {
|
||||
@ApiProperty({ description: 'Unix ms start of the cycle-start calendar day' })
|
||||
cycleStartDate!: number;
|
||||
|
||||
@ApiProperty({ example: 100 })
|
||||
checkInRadiusMeters!: number;
|
||||
|
||||
@ApiProperty()
|
||||
status!: string;
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { haversineDistanceMeters } from './geo-distance';
|
||||
|
||||
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 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,
|
||||
): 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',
|
||||
);
|
||||
}
|
||||
|
||||
export function verifyCustomerCheckIn(
|
||||
target: CustomerCheckInTarget,
|
||||
payload: CheckInPayload,
|
||||
radiusMeters: number,
|
||||
): 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',
|
||||
);
|
||||
}
|
||||
|
||||
function verifyTargetCheckIn(
|
||||
target: BranchCheckInTarget | CustomerCheckInTarget,
|
||||
payload: CheckInPayload,
|
||||
radiusMeters: number,
|
||||
nfcMismatchMessage: string,
|
||||
qrMismatchMessage: string,
|
||||
locationMissingMessage: string,
|
||||
tooFarMessage: string,
|
||||
): 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 {
|
||||
if (target.latitude == null || target.longitude == null) {
|
||||
throw new BadRequestException(locationMissingMessage);
|
||||
}
|
||||
distanceMeters = haversineDistanceMeters(
|
||||
payload.latitude,
|
||||
payload.longitude,
|
||||
target.latitude,
|
||||
target.longitude,
|
||||
);
|
||||
if (distanceMeters > radiusMeters) {
|
||||
throw new BadRequestException(tooFarMessage);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
latitude: payload.latitude,
|
||||
longitude: payload.longitude,
|
||||
distanceMeters:
|
||||
distanceMeters == null
|
||||
? target.latitude != null && target.longitude != null
|
||||
? haversineDistanceMeters(
|
||||
payload.latitude,
|
||||
payload.longitude,
|
||||
target.latitude,
|
||||
target.longitude,
|
||||
)
|
||||
: null
|
||||
: distanceMeters,
|
||||
method: payload.method,
|
||||
photoUrl: payload.photoUrl?.trim() ? payload.photoUrl.trim() : null,
|
||||
};
|
||||
}
|
||||
@@ -27,6 +27,8 @@ 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 FIELD_ATTENDANCE_PRIVILEGE_KEY = 'FIELD.ATTENDANCE';
|
||||
export const FIELD_VISIT_PRIVILEGE_KEY = 'FIELD.VISIT';
|
||||
|
||||
export type FieldResource = 'cycle' | 'plan';
|
||||
|
||||
|
||||
@@ -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,230 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsLatitude,
|
||||
IsLongitude,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
IsUUID,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../../common/http/response';
|
||||
import { CORE_STATUSES } from '../../../../common/value-objects/status/status';
|
||||
import { CHECK_IN_METHODS } from '../../shared/check-in-verification';
|
||||
|
||||
export class VisitCheckInDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID('4')
|
||||
customerId!: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
planId?: string;
|
||||
|
||||
@ApiProperty({ enum: CHECK_IN_METHODS })
|
||||
@IsIn([...CHECK_IN_METHODS])
|
||||
method!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@ValidateIf((dto: VisitCheckInDto) => dto.method === 'nfc')
|
||||
@IsString()
|
||||
nfcId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@ValidateIf((dto: VisitCheckInDto) => dto.method === 'qr')
|
||||
@IsString()
|
||||
qrCode?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsLatitude()
|
||||
latitude!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsLongitude()
|
||||
longitude!: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsUrl({ require_protocol: true, protocols: ['https'] })
|
||||
photoUrl?: string;
|
||||
}
|
||||
|
||||
export class VisitCheckOutDto {
|
||||
@ApiProperty({ enum: CHECK_IN_METHODS })
|
||||
@IsIn([...CHECK_IN_METHODS])
|
||||
method!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@ValidateIf((dto: VisitCheckOutDto) => dto.method === 'nfc')
|
||||
@IsString()
|
||||
nfcId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@ValidateIf((dto: VisitCheckOutDto) => dto.method === 'qr')
|
||||
@IsString()
|
||||
qrCode?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsLatitude()
|
||||
latitude!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsLongitude()
|
||||
longitude!: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsUrl({ require_protocol: true, protocols: ['https'] })
|
||||
photoUrl?: string;
|
||||
}
|
||||
|
||||
export class UpdateVisitStatusDto {
|
||||
@ApiProperty({ enum: CORE_STATUSES })
|
||||
@IsIn([...CORE_STATUSES])
|
||||
status!: string;
|
||||
}
|
||||
|
||||
export class BulkIdsDto {
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
ids!: string[];
|
||||
}
|
||||
|
||||
export class BulkStatusDto {
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
ids!: string[];
|
||||
|
||||
@ApiProperty({ enum: CORE_STATUSES })
|
||||
@IsIn([...CORE_STATUSES])
|
||||
status!: string;
|
||||
}
|
||||
|
||||
export class ListVisitsQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
employeeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
customerId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Unix ms start of calendar day' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
date?: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: CORE_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...CORE_STATUSES])
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
}
|
||||
|
||||
class RelationDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
code!: string;
|
||||
|
||||
@ApiProperty()
|
||||
name!: string;
|
||||
}
|
||||
|
||||
class UserRelationDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
username!: string;
|
||||
}
|
||||
|
||||
export class VisitDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ type: RelationDto })
|
||||
employee!: RelationDto;
|
||||
|
||||
@ApiProperty({ type: RelationDto })
|
||||
customer!: RelationDto;
|
||||
|
||||
@ApiProperty({ format: 'uuid', nullable: true })
|
||||
attendanceId!: string | null;
|
||||
|
||||
@ApiProperty({ format: 'uuid', nullable: true })
|
||||
planId!: string | null;
|
||||
|
||||
@ApiProperty({ format: 'uuid', nullable: true })
|
||||
planDestinationId!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
date!: number;
|
||||
|
||||
@ApiProperty()
|
||||
checkInAt!: number;
|
||||
|
||||
@ApiProperty({ enum: CHECK_IN_METHODS })
|
||||
checkInMethod!: string;
|
||||
|
||||
@ApiProperty()
|
||||
checkInLatitude!: number;
|
||||
|
||||
@ApiProperty()
|
||||
checkInLongitude!: number;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
checkInPhotoUrl!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
checkInDistanceMeters!: number | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
checkOutAt!: number | null;
|
||||
|
||||
@ApiProperty({ enum: CHECK_IN_METHODS, nullable: true })
|
||||
checkOutMethod!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
checkOutLatitude!: number | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
checkOutLongitude!: number | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
checkOutPhotoUrl!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
checkOutDistanceMeters!: number | null;
|
||||
|
||||
@ApiProperty()
|
||||
status!: string;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt!: number;
|
||||
|
||||
@ApiProperty()
|
||||
updatedAt!: number;
|
||||
|
||||
@ApiProperty({ type: UserRelationDto })
|
||||
createdBy!: UserRelationDto;
|
||||
|
||||
@ApiProperty({ type: UserRelationDto })
|
||||
updatedBy!: UserRelationDto;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import type { CheckInMethod } from '../shared/check-in-verification';
|
||||
|
||||
export type Visit = {
|
||||
readonly id: string;
|
||||
readonly employeeId: string;
|
||||
readonly customerId: string;
|
||||
readonly attendanceId: string | null;
|
||||
readonly planId: string | null;
|
||||
readonly planDestinationId: string | null;
|
||||
readonly date: DateTime;
|
||||
readonly checkInAt: DateTime;
|
||||
readonly checkInMethod: CheckInMethod;
|
||||
readonly checkInLatitude: number;
|
||||
readonly checkInLongitude: number;
|
||||
readonly checkInPhotoUrl: string | null;
|
||||
readonly checkInDistanceMeters: number | null;
|
||||
readonly checkOutAt: DateTime | null;
|
||||
readonly checkOutMethod: CheckInMethod | null;
|
||||
readonly checkOutLatitude: number | null;
|
||||
readonly checkOutLongitude: number | null;
|
||||
readonly checkOutPhotoUrl: string | null;
|
||||
readonly checkOutDistanceMeters: number | null;
|
||||
readonly status: Status;
|
||||
readonly createdAt: DateTime;
|
||||
readonly updatedAt: DateTime;
|
||||
readonly createdBy: string;
|
||||
readonly updatedBy: string;
|
||||
readonly employee: { id: string; code: string; name: string } | null;
|
||||
readonly customer: { id: string; code: string; name: string } | null;
|
||||
readonly createdByUser: { id: string; username: string } | null;
|
||||
readonly updatedByUser: { id: string; username: string } | null;
|
||||
};
|
||||
|
||||
export type ListVisitsFilters = {
|
||||
readonly employeeId?: string;
|
||||
readonly customerId?: string;
|
||||
readonly date?: number;
|
||||
readonly status?: string;
|
||||
readonly search?: string;
|
||||
readonly orderBy?: string;
|
||||
readonly orderType?: string;
|
||||
readonly limit: number;
|
||||
readonly offset: number;
|
||||
};
|
||||
|
||||
export type CreateVisitInput = {
|
||||
readonly employeeId: string;
|
||||
readonly customerId: string;
|
||||
readonly attendanceId: string | null;
|
||||
readonly planId: string | null;
|
||||
readonly planDestinationId: string | null;
|
||||
readonly date: DateTime;
|
||||
readonly checkInAt: DateTime;
|
||||
readonly checkInMethod: CheckInMethod;
|
||||
readonly checkInLatitude: number;
|
||||
readonly checkInLongitude: number;
|
||||
readonly checkInPhotoUrl: string | null;
|
||||
readonly checkInDistanceMeters: number | null;
|
||||
readonly userId: string;
|
||||
};
|
||||
|
||||
export type CheckOutVisitInput = {
|
||||
readonly checkOutAt: DateTime;
|
||||
readonly checkOutMethod: CheckInMethod;
|
||||
readonly checkOutLatitude: number;
|
||||
readonly checkOutLongitude: number;
|
||||
readonly checkOutPhotoUrl: string | null;
|
||||
readonly checkOutDistanceMeters: number | null;
|
||||
readonly userId: string;
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiForbiddenResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
||||
import {
|
||||
Pagination,
|
||||
type PaginationResponse,
|
||||
PaginationMetaDto,
|
||||
} from '../../../common/http/response';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import { FIELD_VISIT_PRIVILEGE_KEY } from '../shared/field-purpose';
|
||||
import { ListVisitsQueryDto, VisitDto } from './dto/visit.dto';
|
||||
import { VisitsService } from './visits.service';
|
||||
|
||||
@ApiTags('visits')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('visits')
|
||||
export class VisitsReadController {
|
||||
constructor(private readonly visitsService: VisitsService) {}
|
||||
|
||||
@Get('current')
|
||||
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'view')
|
||||
@ApiOperation({ summary: 'Get the open visit for the current user' })
|
||||
@ApiOkResponse({ type: VisitDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
current(@CurrentUser('id') userId: string): Promise<VisitDto | null> {
|
||||
return this.visitsService.findCurrent(userId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Pagination()
|
||||
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'view')
|
||||
@ApiOperation({ summary: 'List visits' })
|
||||
@ApiOkResponse({
|
||||
schema: {
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/VisitDto' },
|
||||
},
|
||||
meta: { $ref: '#/components/schemas/PaginationMetaDto' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
list(
|
||||
@Query() query: ListVisitsQueryDto,
|
||||
): Promise<PaginationResponse<VisitDto>> {
|
||||
return this.visitsService.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'view')
|
||||
@ApiOperation({ summary: 'Get visit detail' })
|
||||
@ApiOkResponse({ type: VisitDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<VisitDto> {
|
||||
return this.visitsService.findById(id);
|
||||
}
|
||||
}
|
||||
|
||||
void PaginationMetaDto;
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiCreatedResponse,
|
||||
ApiForbiddenResponse,
|
||||
ApiNoContentResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import { FIELD_VISIT_PRIVILEGE_KEY } from '../shared/field-purpose';
|
||||
import {
|
||||
BulkIdsDto,
|
||||
BulkStatusDto,
|
||||
UpdateVisitStatusDto,
|
||||
VisitCheckInDto,
|
||||
VisitCheckOutDto,
|
||||
VisitDto,
|
||||
} from './dto/visit.dto';
|
||||
import { VisitsService } from './visits.service';
|
||||
|
||||
@ApiTags('visits')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('visits')
|
||||
export class VisitsWriteController {
|
||||
constructor(private readonly visitsService: VisitsService) {}
|
||||
|
||||
@Post('check-in')
|
||||
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'create')
|
||||
@ApiOperation({ summary: 'Check in at a customer location' })
|
||||
@ApiCreatedResponse({ type: VisitDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
checkIn(
|
||||
@Body() dto: VisitCheckInDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<VisitDto> {
|
||||
return this.visitsService.checkIn(dto, userId);
|
||||
}
|
||||
|
||||
@Post('bulk-delete')
|
||||
@HttpCode(200)
|
||||
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'delete')
|
||||
@ApiOperation({ summary: 'Bulk delete visits' })
|
||||
@ApiOkResponse({ schema: { properties: { deleted: { type: 'number' } } } })
|
||||
bulkDelete(@Body() dto: BulkIdsDto): Promise<{ deleted: number }> {
|
||||
return this.visitsService.bulkDelete(dto.ids);
|
||||
}
|
||||
|
||||
@Post('bulk-status')
|
||||
@HttpCode(200)
|
||||
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Bulk update visit status' })
|
||||
@ApiOkResponse({ schema: { properties: { updated: { type: 'number' } } } })
|
||||
bulkStatus(
|
||||
@Body() dto: BulkStatusDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<{ updated: number }> {
|
||||
return this.visitsService.bulkUpdateStatus(dto.ids, dto.status, userId);
|
||||
}
|
||||
|
||||
@Post(':id/check-out')
|
||||
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Check out from a customer visit' })
|
||||
@ApiOkResponse({ type: VisitDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
checkOut(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: VisitCheckOutDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<VisitDto> {
|
||||
return this.visitsService.checkOut(id, dto, userId);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Update visit status' })
|
||||
@ApiOkResponse({ type: VisitDto })
|
||||
updateStatus(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateVisitStatusDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<VisitDto> {
|
||||
return this.visitsService.updateStatus(id, dto.status, userId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'delete')
|
||||
@ApiOperation({ summary: 'Delete visit' })
|
||||
@ApiNoContentResponse()
|
||||
@ApiNotFoundResponse()
|
||||
delete(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||
return this.visitsService.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { and, count, eq, ilike, inArray, isNull, or, SQL } from 'drizzle-orm';
|
||||
import { alias } from 'drizzle-orm/pg-core';
|
||||
import { toOrderClauses } from '../../../common/http/response/order-clause';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||
import { customers } from '../../../database/customers-table';
|
||||
import { planDestinations } from '../../../database/plans-table';
|
||||
import { visits, type VisitRow } from '../../../database/visits-table';
|
||||
import { employees, users } from '../../../database/schema';
|
||||
import type {
|
||||
CheckOutVisitInput,
|
||||
CreateVisitInput,
|
||||
ListVisitsFilters,
|
||||
Visit,
|
||||
} from './visit';
|
||||
import type { CheckInMethod } from '../shared/check-in-verification';
|
||||
|
||||
const VISIT_ORDER_COLUMNS = {
|
||||
id: visits.id,
|
||||
date: visits.date,
|
||||
status: visits.status,
|
||||
createdAt: visits.createdAt,
|
||||
updatedAt: visits.updatedAt,
|
||||
};
|
||||
|
||||
const createdByUsers = alias(users, 'visit_created_by_users');
|
||||
const updatedByUsers = alias(users, 'visit_updated_by_users');
|
||||
|
||||
type VisitJoinedRow = {
|
||||
visit: VisitRow;
|
||||
employee: typeof employees.$inferSelect;
|
||||
customer: typeof customers.$inferSelect;
|
||||
createdByUser: typeof users.$inferSelect | null;
|
||||
updatedByUser: typeof users.$inferSelect | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class VisitsRepository {
|
||||
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||
|
||||
async list(
|
||||
filters: ListVisitsFilters,
|
||||
): Promise<{ data: Visit[]; total: number }> {
|
||||
const where = this.buildListWhere(filters);
|
||||
const totalRows = await this.db
|
||||
.select({ total: count() })
|
||||
.from(visits)
|
||||
.where(where);
|
||||
const totalRow = totalRows[0];
|
||||
|
||||
const rows = await this.selectWithRelations()
|
||||
.where(where)
|
||||
.orderBy(
|
||||
...toOrderClauses(VISIT_ORDER_COLUMNS, filters, [
|
||||
{ column: 'date', type: 'DESC' },
|
||||
]),
|
||||
)
|
||||
.limit(filters.limit)
|
||||
.offset(filters.offset);
|
||||
|
||||
return {
|
||||
data: rows.map((row) => this.toDomain(row)),
|
||||
total: Number(totalRow?.total ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Visit | null> {
|
||||
const rows = await this.selectWithRelations()
|
||||
.where(eq(visits.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
|
||||
async findOpenByEmployeeId(employeeId: string): Promise<Visit | null> {
|
||||
const rows = await this.selectWithRelations()
|
||||
.where(and(eq(visits.employeeId, employeeId), isNull(visits.checkOutAt)))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
|
||||
async findPlanDestinationId(
|
||||
planId: string,
|
||||
customerId: string,
|
||||
): Promise<string | null> {
|
||||
const rows = await this.db
|
||||
.select({ id: planDestinations.id })
|
||||
.from(planDestinations)
|
||||
.where(
|
||||
and(
|
||||
eq(planDestinations.planId, planId),
|
||||
eq(planDestinations.customerId, customerId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0]?.id ?? null;
|
||||
}
|
||||
|
||||
async create(input: CreateVisitInput): Promise<Visit> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
try {
|
||||
const inserted = await this.db
|
||||
.insert(visits)
|
||||
.values({
|
||||
employeeId: input.employeeId,
|
||||
customerId: input.customerId,
|
||||
attendanceId: input.attendanceId,
|
||||
planId: input.planId,
|
||||
planDestinationId: input.planDestinationId,
|
||||
date: input.date.value,
|
||||
checkInAt: input.checkInAt.value,
|
||||
checkInMethod: input.checkInMethod,
|
||||
checkInLatitude: input.checkInLatitude,
|
||||
checkInLongitude: input.checkInLongitude,
|
||||
checkInPhotoUrl: input.checkInPhotoUrl,
|
||||
checkInDistanceMeters: input.checkInDistanceMeters,
|
||||
status: Status.create('active').value,
|
||||
createdAt: now.value,
|
||||
updatedAt: now.value,
|
||||
createdBy: input.userId,
|
||||
updatedBy: input.userId,
|
||||
})
|
||||
.returning();
|
||||
const created = await this.findById(inserted[0].id);
|
||||
if (!created) {
|
||||
throw new NotFoundException('Visit not found');
|
||||
}
|
||||
return created;
|
||||
} catch (error) {
|
||||
this.rethrowUniqueViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async checkOut(id: string, input: CheckOutVisitInput): Promise<Visit> {
|
||||
const existing = await this.findById(id);
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Visit not found');
|
||||
}
|
||||
if (existing.checkOutAt) {
|
||||
throw new ConflictException('Visit is already checked out');
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
await this.db
|
||||
.update(visits)
|
||||
.set({
|
||||
checkOutAt: input.checkOutAt.value,
|
||||
checkOutMethod: input.checkOutMethod,
|
||||
checkOutLatitude: input.checkOutLatitude,
|
||||
checkOutLongitude: input.checkOutLongitude,
|
||||
checkOutPhotoUrl: input.checkOutPhotoUrl,
|
||||
checkOutDistanceMeters: input.checkOutDistanceMeters,
|
||||
updatedAt: now.value,
|
||||
updatedBy: input.userId,
|
||||
})
|
||||
.where(eq(visits.id, id));
|
||||
const updated = await this.findById(id);
|
||||
if (!updated) {
|
||||
throw new NotFoundException('Visit not found');
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
status: Status,
|
||||
userId: string,
|
||||
): Promise<Visit> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const updated = await this.db
|
||||
.update(visits)
|
||||
.set({
|
||||
status: status.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(eq(visits.id, id))
|
||||
.returning({ id: visits.id });
|
||||
if (updated.length === 0) {
|
||||
throw new NotFoundException('Visit not found');
|
||||
}
|
||||
const row = await this.findById(id);
|
||||
if (!row) {
|
||||
throw new NotFoundException('Visit not found');
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
ids: string[],
|
||||
status: Status,
|
||||
userId: string,
|
||||
): Promise<number> {
|
||||
if (ids.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const rows = await this.db
|
||||
.update(visits)
|
||||
.set({
|
||||
status: status.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(inArray(visits.id, ids))
|
||||
.returning({ id: visits.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const deleted = await this.db
|
||||
.delete(visits)
|
||||
.where(eq(visits.id, id))
|
||||
.returning({ id: visits.id });
|
||||
if (deleted.length === 0) {
|
||||
throw new NotFoundException('Visit not found');
|
||||
}
|
||||
}
|
||||
|
||||
async bulkDelete(ids: string[]): Promise<number> {
|
||||
if (ids.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const deleted = await this.db
|
||||
.delete(visits)
|
||||
.where(inArray(visits.id, ids))
|
||||
.returning({ id: visits.id });
|
||||
return deleted.length;
|
||||
}
|
||||
|
||||
private selectWithRelations() {
|
||||
return this.db
|
||||
.select({
|
||||
visit: visits,
|
||||
employee: employees,
|
||||
customer: customers,
|
||||
createdByUser: createdByUsers,
|
||||
updatedByUser: updatedByUsers,
|
||||
})
|
||||
.from(visits)
|
||||
.innerJoin(employees, eq(visits.employeeId, employees.id))
|
||||
.innerJoin(customers, eq(visits.customerId, customers.id))
|
||||
.leftJoin(createdByUsers, eq(visits.createdBy, createdByUsers.id))
|
||||
.leftJoin(updatedByUsers, eq(visits.updatedBy, updatedByUsers.id));
|
||||
}
|
||||
|
||||
private buildListWhere(filters: ListVisitsFilters): SQL | undefined {
|
||||
const parts: SQL[] = [];
|
||||
if (filters.employeeId) {
|
||||
parts.push(eq(visits.employeeId, filters.employeeId));
|
||||
}
|
||||
if (filters.customerId) {
|
||||
parts.push(eq(visits.customerId, filters.customerId));
|
||||
}
|
||||
if (filters.date !== undefined) {
|
||||
parts.push(eq(visits.date, filters.date));
|
||||
}
|
||||
if (filters.status) {
|
||||
parts.push(eq(visits.status, filters.status));
|
||||
}
|
||||
if (filters.search) {
|
||||
const search = or(
|
||||
ilike(employees.code, `%${filters.search}%`),
|
||||
ilike(employees.name, `%${filters.search}%`),
|
||||
ilike(customers.code, `%${filters.search}%`),
|
||||
ilike(customers.name, `%${filters.search}%`),
|
||||
);
|
||||
if (search) {
|
||||
parts.push(search);
|
||||
}
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return parts.length === 1 ? parts[0] : and(...parts);
|
||||
}
|
||||
|
||||
private toDomain(row: VisitJoinedRow): Visit {
|
||||
const visit = row.visit;
|
||||
return {
|
||||
id: visit.id,
|
||||
employeeId: visit.employeeId,
|
||||
customerId: visit.customerId,
|
||||
attendanceId: visit.attendanceId,
|
||||
planId: visit.planId,
|
||||
planDestinationId: visit.planDestinationId,
|
||||
date: DateTime.fromUnixMs(visit.date),
|
||||
checkInAt: DateTime.fromUnixMs(visit.checkInAt),
|
||||
checkInMethod: visit.checkInMethod as CheckInMethod,
|
||||
checkInLatitude: visit.checkInLatitude,
|
||||
checkInLongitude: visit.checkInLongitude,
|
||||
checkInPhotoUrl: visit.checkInPhotoUrl,
|
||||
checkInDistanceMeters: visit.checkInDistanceMeters,
|
||||
checkOutAt: visit.checkOutAt
|
||||
? DateTime.fromUnixMs(visit.checkOutAt)
|
||||
: null,
|
||||
checkOutMethod: visit.checkOutMethod as CheckInMethod | null,
|
||||
checkOutLatitude: visit.checkOutLatitude,
|
||||
checkOutLongitude: visit.checkOutLongitude,
|
||||
checkOutPhotoUrl: visit.checkOutPhotoUrl,
|
||||
checkOutDistanceMeters: visit.checkOutDistanceMeters,
|
||||
status: Status.create(visit.status),
|
||||
createdAt: DateTime.fromUnixMs(visit.createdAt),
|
||||
updatedAt: DateTime.fromUnixMs(visit.updatedAt),
|
||||
createdBy: visit.createdBy,
|
||||
updatedBy: visit.updatedBy,
|
||||
employee: {
|
||||
id: row.employee.id,
|
||||
code: row.employee.code,
|
||||
name: row.employee.name,
|
||||
},
|
||||
customer: {
|
||||
id: row.customer.id,
|
||||
code: row.customer.code,
|
||||
name: row.customer.name,
|
||||
},
|
||||
createdByUser: row.createdByUser
|
||||
? { id: row.createdByUser.id, username: row.createdByUser.username }
|
||||
: null,
|
||||
updatedByUser: row.updatedByUser
|
||||
? { id: row.updatedByUser.id, username: row.updatedByUser.username }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
private rethrowUniqueViolation(error: unknown): never {
|
||||
let current: unknown = error;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (!current || typeof current !== 'object') {
|
||||
break;
|
||||
}
|
||||
const obj = current as { code?: string; cause?: unknown };
|
||||
if (obj.code === '23505') {
|
||||
throw new ConflictException(
|
||||
'A visit is already open for this employee',
|
||||
);
|
||||
}
|
||||
current = obj.cause;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
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,
|
||||
} from '../shared/check-in-verification';
|
||||
import { FIELD_VISIT_PRIVILEGE_KEY } from '../shared/field-purpose';
|
||||
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,
|
||||
) {}
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
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,
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
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 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;
|
||||
Reference in New Issue
Block a user