diff --git a/drizzle/migrations/0015_field_check_in.sql b/drizzle/migrations/0015_field_check_in.sql new file mode 100644 index 0000000..7efc729 --- /dev/null +++ b/drizzle/migrations/0015_field_check_in.sql @@ -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); diff --git a/src/database/attendances-table.ts b/src/database/attendances-table.ts new file mode 100644 index 0000000..483be05 --- /dev/null +++ b/src/database/attendances-table.ts @@ -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; diff --git a/src/database/checkpoint-columns.ts b/src/database/checkpoint-columns.ts new file mode 100644 index 0000000..1fbaef1 --- /dev/null +++ b/src/database/checkpoint-columns.ts @@ -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'), +}; diff --git a/src/database/company-settings-table.ts b/src/database/company-settings-table.ts index 7bf4004..74d8673 100644 --- a/src/database/company-settings-table.ts +++ b/src/database/company-settings-table.ts @@ -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), }); diff --git a/src/database/schema.ts b/src/database/schema.ts index 33e780e..7c7c913 100644 --- a/src/database/schema.ts +++ b/src/database/schema.ts @@ -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, diff --git a/src/database/visits-table.ts b/src/database/visits-table.ts new file mode 100644 index 0000000..cd72402 --- /dev/null +++ b/src/database/visits-table.ts @@ -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; diff --git a/src/modules/auth/auth.controller.spec.ts b/src/modules/auth/auth.controller.spec.ts index 60fac9c..f2397f8 100644 --- a/src/modules/auth/auth.controller.spec.ts +++ b/src/modules/auth/auth.controller.spec.ts @@ -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 >; + let employeesService: jest.Mocked< + Pick + >; 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: {}, }); }); diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index bbd0451..23e7930 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -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 { 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, }; } diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 8b93036..9cd8551 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -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({ diff --git a/src/modules/auth/dto/auth.dto.ts b/src/modules/auth/dto/auth.dto.ts index 242dfe7..fd71264 100644 --- a/src/modules/auth/dto/auth.dto.ts +++ b/src/modules/auth/dto/auth.dto.ts @@ -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: { diff --git a/src/modules/configuration/employees/employees.service.ts b/src/modules/configuration/employees/employees.service.ts index cc091a5..d66abc9 100644 --- a/src/modules/configuration/employees/employees.service.ts +++ b/src/modules/configuration/employees/employees.service.ts @@ -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 { + 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; diff --git a/src/modules/field/attendances/attendance.ts b/src/modules/field/attendances/attendance.ts new file mode 100644 index 0000000..6191ca2 --- /dev/null +++ b/src/modules/field/attendances/attendance.ts @@ -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; +}; diff --git a/src/modules/field/attendances/attendances-read.controller.ts b/src/modules/field/attendances/attendances-read.controller.ts new file mode 100644 index 0000000..209361d --- /dev/null +++ b/src/modules/field/attendances/attendances-read.controller.ts @@ -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 { + 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> { + 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 { + return this.attendancesService.findById(id); + } +} + +void PaginationMetaDto; diff --git a/src/modules/field/attendances/attendances-write.controller.ts b/src/modules/field/attendances/attendances-write.controller.ts new file mode 100644 index 0000000..d350d52 --- /dev/null +++ b/src/modules/field/attendances/attendances-write.controller.ts @@ -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 { + 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 { + 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 { + 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 { + return this.attendancesService.delete(id); + } +} diff --git a/src/modules/field/attendances/attendances.repository.ts b/src/modules/field/attendances/attendances.repository.ts new file mode 100644 index 0000000..50f6a5d --- /dev/null +++ b/src/modules/field/attendances/attendances.repository.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/src/modules/field/attendances/attendances.service.ts b/src/modules/field/attendances/attendances.service.ts new file mode 100644 index 0000000..e71c99f --- /dev/null +++ b/src/modules/field/attendances/attendances.service.ts @@ -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> { + 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 { + const attendance = await this.attendancesRepository.findById(id); + if (!attendance) { + throw new NotFoundException('Attendance not found'); + } + return this.toItem(attendance); + } + + async findCurrent(userId: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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, + }; + } +} diff --git a/src/modules/field/attendances/dto/attendance.dto.ts b/src/modules/field/attendances/dto/attendance.dto.ts new file mode 100644 index 0000000..50a5ab1 --- /dev/null +++ b/src/modules/field/attendances/dto/attendance.dto.ts @@ -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; +} diff --git a/src/modules/field/field.module.ts b/src/modules/field/field.module.ts index 5f9b030..54e7e49 100644 --- a/src/modules/field/field.module.ts +++ b/src/modules/field/field.module.ts @@ -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 {} diff --git a/src/modules/field/settings/company-setting.ts b/src/modules/field/settings/company-setting.ts index 2fdacae..8784215 100644 --- a/src/modules/field/settings/company-setting.ts +++ b/src/modules/field/settings/company-setting.ts @@ -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; }; diff --git a/src/modules/field/settings/company-settings.controller.ts b/src/modules/field/settings/company-settings.controller.ts index 7991310..3171580 100644 --- a/src/modules/field/settings/company-settings.controller.ts +++ b/src/modules/field/settings/company-settings.controller.ts @@ -47,6 +47,6 @@ export class CompanySettingsController { @Body() dto: UpdateCompanySettingDto, @CurrentUser('id') userId: string, ): Promise { - return this.companySettingsService.update(dto.cycleStartDate, userId); + return this.companySettingsService.update(dto, userId); } } diff --git a/src/modules/field/settings/company-settings.repository.ts b/src/modules/field/settings/company-settings.repository.ts index a0c8343..fa2eccb 100644 --- a/src/modules/field/settings/company-settings.repository.ts +++ b/src/modules/field/settings/company-settings.repository.ts @@ -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), diff --git a/src/modules/field/settings/company-settings.service.spec.ts b/src/modules/field/settings/company-settings.service.spec.ts index 1e37f2f..b556755 100644 --- a/src/modules/field/settings/company-settings.service.spec.ts +++ b/src/modules/field/settings/company-settings.service.spec.ts @@ -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, ); }); }); diff --git a/src/modules/field/settings/company-settings.service.ts b/src/modules/field/settings/company-settings.service.ts index a09b4a0..e774c52 100644 --- a/src/modules/field/settings/company-settings.service.ts +++ b/src/modules/field/settings/company-settings.service.ts @@ -23,17 +23,41 @@ export class CompanySettingsService { } async update( - cycleStartDateRaw: string, + input: { + cycleStartDate?: string; + checkInRadiusMeters?: number; + }, userId: string, ): Promise> { - 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 { + const setting = await this.companySettingsRepository.find(); + if (!setting) { + throw new NotFoundException('Settings not configured'); + } + return setting.checkInRadiusMeters; + } + async requireCycleStartDate(): Promise { 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, diff --git a/src/modules/field/settings/dto/company-setting.dto.ts b/src/modules/field/settings/dto/company-setting.dto.ts index 9211ba4..743c0b6 100644 --- a/src/modules/field/settings/dto/company-setting.dto.ts +++ b/src/modules/field/settings/dto/company-setting.dto.ts @@ -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; diff --git a/src/modules/field/shared/check-in-verification.spec.ts b/src/modules/field/shared/check-in-verification.spec.ts new file mode 100644 index 0000000..5edda9a --- /dev/null +++ b/src/modules/field/shared/check-in-verification.spec.ts @@ -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'); + }); +}); diff --git a/src/modules/field/shared/check-in-verification.ts b/src/modules/field/shared/check-in-verification.ts new file mode 100644 index 0000000..ea3da68 --- /dev/null +++ b/src/modules/field/shared/check-in-verification.ts @@ -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, + }; +} diff --git a/src/modules/field/shared/field-purpose.ts b/src/modules/field/shared/field-purpose.ts index dd052f4..e584335 100644 --- a/src/modules/field/shared/field-purpose.ts +++ b/src/modules/field/shared/field-purpose.ts @@ -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'; diff --git a/src/modules/field/shared/geo-distance.ts b/src/modules/field/shared/geo-distance.ts new file mode 100644 index 0000000..2707181 --- /dev/null +++ b/src/modules/field/shared/geo-distance.ts @@ -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; +} diff --git a/src/modules/field/visits/dto/visit.dto.ts b/src/modules/field/visits/dto/visit.dto.ts new file mode 100644 index 0000000..cc95fda --- /dev/null +++ b/src/modules/field/visits/dto/visit.dto.ts @@ -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; +} diff --git a/src/modules/field/visits/visit.ts b/src/modules/field/visits/visit.ts new file mode 100644 index 0000000..1eb4bf3 --- /dev/null +++ b/src/modules/field/visits/visit.ts @@ -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; +}; diff --git a/src/modules/field/visits/visits-read.controller.ts b/src/modules/field/visits/visits-read.controller.ts new file mode 100644 index 0000000..9e93b08 --- /dev/null +++ b/src/modules/field/visits/visits-read.controller.ts @@ -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 { + 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> { + 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 { + return this.visitsService.findById(id); + } +} + +void PaginationMetaDto; diff --git a/src/modules/field/visits/visits-write.controller.ts b/src/modules/field/visits/visits-write.controller.ts new file mode 100644 index 0000000..9ae7603 --- /dev/null +++ b/src/modules/field/visits/visits-write.controller.ts @@ -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 { + 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 { + 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 { + 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 { + return this.visitsService.delete(id); + } +} diff --git a/src/modules/field/visits/visits.repository.ts b/src/modules/field/visits/visits.repository.ts new file mode 100644 index 0000000..17e1f75 --- /dev/null +++ b/src/modules/field/visits/visits.repository.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/src/modules/field/visits/visits.service.ts b/src/modules/field/visits/visits.service.ts new file mode 100644 index 0000000..b9759c6 --- /dev/null +++ b/src/modules/field/visits/visits.service.ts @@ -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> { + 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 { + const visit = await this.visitsRepository.findById(id); + if (!visit) { + throw new NotFoundException('Visit not found'); + } + return this.toItem(visit); + } + + async findCurrent(userId: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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;