Add attendance and visit management features with database schema updates

- Introduced new `attendances` and `visits` tables to manage employee attendance and customer visits, including relevant fields for check-in and check-out details.
- Updated `company_settings` to include a `check_in_radius_meters` column for attendance validation.
- Implemented foreign key constraints to ensure data integrity between `attendances`, `visits`, `employees`, `branches`, and other related entities.
- Created new services and controllers for handling attendance and visit operations, including check-in, check-out, and bulk actions.
- Enhanced DTOs for attendance and visit data transfer, including validation for input data.
- Added unit and integration tests to validate the new functionalities and ensure proper handling of attendance and visit records.
- Created migration scripts to apply the necessary database schema changes for the new features.
This commit is contained in:
shancheas
2026-09-01 12:27:51 +07:00
parent 0e73d14381
commit 365a37b8d2
34 changed files with 2771 additions and 13 deletions
@@ -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);
+37
View File
@@ -0,0 +1,37 @@
import { sql } from 'drizzle-orm';
import { bigint, index, pgTable, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
import { branches } from './branches-table';
import { checkInColumns, checkOutColumns } from './checkpoint-columns';
import { employees } from './employees-table';
import { primaryEntityColumns } from './primary-entity-columns';
import { users } from './schema';
export const attendances = pgTable(
'attendances',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
employeeId: uuid('employee_id')
.notNull()
.references(() => employees.id, { onDelete: 'restrict' }),
branchId: uuid('branch_id')
.notNull()
.references(() => branches.id, { onDelete: 'restrict' }),
date: bigint('date', { mode: 'number' }).notNull(),
...checkInColumns,
...checkOutColumns,
...primaryEntityColumns(users),
},
(t) => [
uniqueIndex('attendances_employee_date_live_unique')
.on(t.employeeId, t.date)
.where(sql`${t.status} <> 'archived'`),
uniqueIndex('attendances_employee_open_unique')
.on(t.employeeId)
.where(sql`${t.checkOutAt} IS NULL AND ${t.status} <> 'archived'`),
index('attendances_employee_id_idx').on(t.employeeId),
index('attendances_branch_id_idx').on(t.branchId),
],
);
export type AttendanceRow = typeof attendances.$inferSelect;
export type NewAttendanceRow = typeof attendances.$inferInsert;
+19
View File
@@ -0,0 +1,19 @@
import { bigint, doublePrecision, integer, text } from 'drizzle-orm/pg-core';
export const checkInColumns = {
checkInAt: bigint('check_in_at', { mode: 'number' }).notNull(),
checkInMethod: text('check_in_method').notNull(),
checkInLatitude: doublePrecision('check_in_latitude').notNull(),
checkInLongitude: doublePrecision('check_in_longitude').notNull(),
checkInPhotoUrl: text('check_in_photo_url'),
checkInDistanceMeters: integer('check_in_distance_meters'),
};
export const checkOutColumns = {
checkOutAt: bigint('check_out_at', { mode: 'number' }),
checkOutMethod: text('check_out_method'),
checkOutLatitude: doublePrecision('check_out_latitude'),
checkOutLongitude: doublePrecision('check_out_longitude'),
checkOutPhotoUrl: text('check_out_photo_url'),
checkOutDistanceMeters: integer('check_out_distance_meters'),
};
+2 -1
View File
@@ -1,4 +1,4 @@
import { bigint, pgTable, uuid } from 'drizzle-orm/pg-core';
import { bigint, integer, pgTable, uuid } from 'drizzle-orm/pg-core';
import { primaryEntityColumns } from './primary-entity-columns';
import { users } from './schema';
@@ -8,6 +8,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),
});
+6
View File
@@ -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,
+47
View File
@@ -0,0 +1,47 @@
import { sql } from 'drizzle-orm';
import { bigint, index, pgTable, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
import { attendances } from './attendances-table';
import { checkInColumns, checkOutColumns } from './checkpoint-columns';
import { customers } from './customers-table';
import { employees } from './employees-table';
import { planDestinations, plans } from './plans-table';
import { primaryEntityColumns } from './primary-entity-columns';
import { users } from './schema';
export const visits = pgTable(
'visits',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
employeeId: uuid('employee_id')
.notNull()
.references(() => employees.id, { onDelete: 'restrict' }),
customerId: uuid('customer_id')
.notNull()
.references(() => customers.id, { onDelete: 'restrict' }),
attendanceId: uuid('attendance_id').references(() => attendances.id, {
onDelete: 'set null',
}),
planId: uuid('plan_id').references(() => plans.id, {
onDelete: 'set null',
}),
planDestinationId: uuid('plan_destination_id').references(
() => planDestinations.id,
{ onDelete: 'set null' },
),
date: bigint('date', { mode: 'number' }).notNull(),
...checkInColumns,
...checkOutColumns,
...primaryEntityColumns(users),
},
(t) => [
uniqueIndex('visits_employee_open_unique')
.on(t.employeeId)
.where(sql`${t.checkOutAt} IS NULL AND ${t.status} <> 'archived'`),
index('visits_employee_id_idx').on(t.employeeId),
index('visits_customer_id_idx').on(t.customerId),
index('visits_attendance_id_idx').on(t.attendanceId),
],
);
export type VisitRow = typeof visits.$inferSelect;
export type NewVisitRow = typeof visits.$inferInsert;
+10
View File
@@ -1,5 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PrivilegesService } from '../privileges/privileges.service';
import { EmployeesService } from '../configuration/employees/employees.service';
import { UsersService } from '../users/users.service';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
@@ -13,6 +14,9 @@ describe('AuthController', () => {
let privilegesService: jest.Mocked<
Pick<PrivilegesService, 'findPrivilegeSummary' | 'getPermissionsMap'>
>;
let employeesService: jest.Mocked<
Pick<EmployeesService, 'findRelationByUserId'>
>;
beforeEach(async () => {
authService = {
@@ -43,6 +47,9 @@ describe('AuthController', () => {
findPrivilegeSummary: jest.fn(),
getPermissionsMap: jest.fn(),
};
employeesService = {
findRelationByUserId: jest.fn().mockResolvedValue(null),
};
const moduleRef: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
@@ -50,6 +57,7 @@ describe('AuthController', () => {
{ provide: AuthService, useValue: authService },
{ provide: UsersService, useValue: usersService },
{ provide: PrivilegesService, useValue: privilegesService },
{ provide: EmployeesService, useValue: employeesService },
],
}).compile();
@@ -91,6 +99,7 @@ describe('AuthController', () => {
username: 'alice',
isSuperadmin: false,
privilege: null,
employee: null,
permissions: {},
});
});
@@ -153,6 +162,7 @@ describe('AuthController', () => {
username: 'alice',
isSuperadmin: true,
privilege: null,
employee: null,
permissions: {},
});
});
+6
View File
@@ -17,6 +17,7 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Public } from '../../common/decorators/public.decorator';
import { BEARER_AUTH_NAME } from '../../common/swagger/setup-swagger';
import { PrivilegesService } from '../privileges/privileges.service';
import { EmployeesService } from '../configuration/employees/employees.service';
import { UsersService } from '../users/users.service';
import { AuthService } from './auth.service';
import {
@@ -35,6 +36,7 @@ export class AuthController {
private readonly authService: AuthService,
private readonly usersService: UsersService,
private readonly privilegesService: PrivilegesService,
private readonly employeesService: EmployeesService,
) {}
@Public()
@@ -97,12 +99,15 @@ export class AuthController {
async me(@CurrentUser() user: AuthUser): Promise<MeResponseDto> {
const full = await this.usersService.findById(user.id);
const isSuperadmin = full?.isSuperadmin ?? user.isSuperadmin;
const employee = await this.employeesService.findRelationByUserId(user.id);
if (!full?.privilegeId) {
return {
id: user.id,
username: user.username,
isSuperadmin,
privilege: null,
employee,
permissions: {},
};
}
@@ -119,6 +124,7 @@ export class AuthController {
username: user.username,
isSuperadmin,
privilege,
employee,
permissions,
};
}
+2
View File
@@ -7,6 +7,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PrivilegesGuard } from '../../common/guards/privileges.guard';
import { PrivilegesModule } from '../privileges/privileges.module';
import { EmployeesModule } from '../configuration/employees/employees.module';
import { UsersModule } from '../users/users.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
@@ -17,6 +18,7 @@ import { JwtStrategy } from './strategies/jwt.strategy';
@Module({
imports: [
UsersModule,
EmployeesModule,
PrivilegesModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
+17
View File
@@ -90,6 +90,20 @@ export class MePrivilegeDto {
code!: string;
}
export class MeEmployeeDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty()
code!: string;
@ApiProperty()
name!: string;
@ApiProperty({ example: 'sales' })
position!: string;
}
export class MeResponseDto {
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
id!: string;
@@ -103,6 +117,9 @@ export class MeResponseDto {
@ApiProperty({ type: MePrivilegeDto, nullable: true })
privilege!: MePrivilegeDto | null;
@ApiProperty({ type: MeEmployeeDto, nullable: true })
employee!: MeEmployeeDto | null;
@ApiProperty({
description: 'Permission matrix keyed by privilege key code',
example: {
@@ -117,6 +117,32 @@ export class EmployeesService {
return this.toListItem(employee);
}
async findRelationByUserId(userId: string): Promise<{
id: string;
code: string;
name: string;
position: string;
} | null> {
const employee = await this.employeesRepository.findByUserId(userId);
if (!employee) {
return null;
}
return {
id: employee.id,
code: employee.code,
name: employee.name,
position: employee.position,
};
}
async requireByUserId(userId: string): Promise<Employee> {
const employee = await this.employeesRepository.findByUserId(userId);
if (!employee) {
throw new BadRequestException('User is not linked to an employee');
}
return employee;
}
async create(input: {
code: string;
name: string;
@@ -0,0 +1,71 @@
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
import type { CheckInMethod } from '../shared/check-in-verification';
export type Attendance = {
readonly id: string;
readonly employeeId: string;
readonly branchId: string;
readonly date: DateTime;
readonly checkInAt: DateTime;
readonly checkInMethod: CheckInMethod;
readonly checkInLatitude: number;
readonly checkInLongitude: number;
readonly checkInPhotoUrl: string | null;
readonly checkInDistanceMeters: number | null;
readonly checkOutAt: DateTime | null;
readonly checkOutMethod: CheckInMethod | null;
readonly checkOutLatitude: number | null;
readonly checkOutLongitude: number | null;
readonly checkOutPhotoUrl: string | null;
readonly checkOutDistanceMeters: number | null;
readonly status: Status;
readonly createdAt: DateTime;
readonly updatedAt: DateTime;
readonly createdBy: string;
readonly updatedBy: string;
readonly employee: { id: string; code: string; name: string } | null;
readonly branch: {
id: string;
code: string;
name: string;
division: { id: string; code: string; name: string } | null;
} | null;
readonly createdByUser: { id: string; username: string } | null;
readonly updatedByUser: { id: string; username: string } | null;
};
export type ListAttendancesFilters = {
readonly employeeId?: string;
readonly branchId?: string;
readonly date?: number;
readonly status?: string;
readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly limit: number;
readonly offset: number;
};
export type CreateAttendanceInput = {
readonly employeeId: string;
readonly branchId: string;
readonly date: DateTime;
readonly checkInAt: DateTime;
readonly checkInMethod: CheckInMethod;
readonly checkInLatitude: number;
readonly checkInLongitude: number;
readonly checkInPhotoUrl: string | null;
readonly checkInDistanceMeters: number | null;
readonly userId: string;
};
export type CheckOutAttendanceInput = {
readonly checkOutAt: DateTime;
readonly checkOutMethod: CheckInMethod;
readonly checkOutLatitude: number;
readonly checkOutLongitude: number;
readonly checkOutPhotoUrl: string | null;
readonly checkOutDistanceMeters: number | null;
readonly userId: string;
};
@@ -0,0 +1,77 @@
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
import {
ApiBearerAuth,
ApiForbiddenResponse,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
import {
Pagination,
type PaginationResponse,
PaginationMetaDto,
} from '../../../common/http/response';
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
import { FIELD_ATTENDANCE_PRIVILEGE_KEY } from '../shared/field-purpose';
import { AttendanceDto, ListAttendancesQueryDto } from './dto/attendance.dto';
import { AttendancesService } from './attendances.service';
@ApiTags('attendances')
@ApiBearerAuth(BEARER_AUTH_NAME)
@Controller('attendances')
export class AttendancesReadController {
constructor(private readonly attendancesService: AttendancesService) {}
@Get('current')
@RequirePrivilege(FIELD_ATTENDANCE_PRIVILEGE_KEY, 'view')
@ApiOperation({ summary: 'Get the open attendance for the current user' })
@ApiOkResponse({
type: AttendanceDto,
description: 'Null when no open shift',
})
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
current(@CurrentUser('id') userId: string): Promise<AttendanceDto | null> {
return this.attendancesService.findCurrent(userId);
}
@Get()
@Pagination()
@RequirePrivilege(FIELD_ATTENDANCE_PRIVILEGE_KEY, 'view')
@ApiOperation({ summary: 'List attendances' })
@ApiOkResponse({
schema: {
properties: {
data: {
type: 'array',
items: { $ref: '#/components/schemas/AttendanceDto' },
},
meta: { $ref: '#/components/schemas/PaginationMetaDto' },
},
},
})
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
list(
@Query() query: ListAttendancesQueryDto,
): Promise<PaginationResponse<AttendanceDto>> {
return this.attendancesService.list(query);
}
@Get(':id')
@RequirePrivilege(FIELD_ATTENDANCE_PRIVILEGE_KEY, 'view')
@ApiOperation({ summary: 'Get attendance detail' })
@ApiOkResponse({ type: AttendanceDto })
@ApiNotFoundResponse()
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<AttendanceDto> {
return this.attendancesService.findById(id);
}
}
void PaginationMetaDto;
@@ -0,0 +1,116 @@
import {
Body,
Controller,
Delete,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiCreatedResponse,
ApiForbiddenResponse,
ApiNoContentResponse,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
import { FIELD_ATTENDANCE_PRIVILEGE_KEY } from '../shared/field-purpose';
import {
AttendanceCheckInDto,
AttendanceCheckOutDto,
AttendanceDto,
BulkIdsDto,
BulkStatusDto,
UpdateAttendanceStatusDto,
} from './dto/attendance.dto';
import { AttendancesService } from './attendances.service';
@ApiTags('attendances')
@ApiBearerAuth(BEARER_AUTH_NAME)
@Controller('attendances')
export class AttendancesWriteController {
constructor(private readonly attendancesService: AttendancesService) {}
@Post('check-in')
@RequirePrivilege(FIELD_ATTENDANCE_PRIVILEGE_KEY, 'create')
@ApiOperation({ summary: 'Check in at a branch' })
@ApiCreatedResponse({ type: AttendanceDto })
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
checkIn(
@Body() dto: AttendanceCheckInDto,
@CurrentUser('id') userId: string,
): Promise<AttendanceDto> {
return this.attendancesService.checkIn(dto, userId);
}
@Post('bulk-delete')
@HttpCode(200)
@RequirePrivilege(FIELD_ATTENDANCE_PRIVILEGE_KEY, 'delete')
@ApiOperation({ summary: 'Bulk delete attendances' })
@ApiOkResponse({ schema: { properties: { deleted: { type: 'number' } } } })
bulkDelete(@Body() dto: BulkIdsDto): Promise<{ deleted: number }> {
return this.attendancesService.bulkDelete(dto.ids);
}
@Post('bulk-status')
@HttpCode(200)
@RequirePrivilege(FIELD_ATTENDANCE_PRIVILEGE_KEY, 'update')
@ApiOperation({ summary: 'Bulk update attendance status' })
@ApiOkResponse({ schema: { properties: { updated: { type: 'number' } } } })
bulkStatus(
@Body() dto: BulkStatusDto,
@CurrentUser('id') userId: string,
): Promise<{ updated: number }> {
return this.attendancesService.bulkUpdateStatus(
dto.ids,
dto.status,
userId,
);
}
@Post(':id/check-out')
@RequirePrivilege(FIELD_ATTENDANCE_PRIVILEGE_KEY, 'update')
@ApiOperation({ summary: 'Check out from a branch shift' })
@ApiOkResponse({ type: AttendanceDto })
@ApiNotFoundResponse()
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
checkOut(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AttendanceCheckOutDto,
@CurrentUser('id') userId: string,
): Promise<AttendanceDto> {
return this.attendancesService.checkOut(id, dto, userId);
}
@Patch(':id/status')
@RequirePrivilege(FIELD_ATTENDANCE_PRIVILEGE_KEY, 'update')
@ApiOperation({ summary: 'Update attendance status' })
@ApiOkResponse({ type: AttendanceDto })
updateStatus(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateAttendanceStatusDto,
@CurrentUser('id') userId: string,
): Promise<AttendanceDto> {
return this.attendancesService.updateStatus(id, dto.status, userId);
}
@Delete(':id')
@HttpCode(204)
@RequirePrivilege(FIELD_ATTENDANCE_PRIVILEGE_KEY, 'delete')
@ApiOperation({ summary: 'Delete attendance' })
@ApiNoContentResponse()
@ApiNotFoundResponse()
delete(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
return this.attendancesService.delete(id);
}
}
@@ -0,0 +1,344 @@
import {
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { and, count, eq, ilike, inArray, isNull, or, SQL } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core';
import { toOrderClauses } from '../../../common/http/response/order-clause';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
import {
attendances,
type AttendanceRow,
} from '../../../database/attendances-table';
import { branches } from '../../../database/branches-table';
import { divisions, employees, users } from '../../../database/schema';
import type {
Attendance,
CheckOutAttendanceInput,
CreateAttendanceInput,
ListAttendancesFilters,
} from './attendance';
import type { CheckInMethod } from '../shared/check-in-verification';
const ATTENDANCE_ORDER_COLUMNS = {
id: attendances.id,
date: attendances.date,
status: attendances.status,
createdAt: attendances.createdAt,
updatedAt: attendances.updatedAt,
};
const createdByUsers = alias(users, 'attendance_created_by_users');
const updatedByUsers = alias(users, 'attendance_updated_by_users');
type AttendanceJoinedRow = {
attendance: AttendanceRow;
employee: typeof employees.$inferSelect;
branch: typeof branches.$inferSelect;
division: typeof divisions.$inferSelect | null;
createdByUser: typeof users.$inferSelect | null;
updatedByUser: typeof users.$inferSelect | null;
};
@Injectable()
export class AttendancesRepository {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
async list(
filters: ListAttendancesFilters,
): Promise<{ data: Attendance[]; total: number }> {
const where = this.buildListWhere(filters);
const totalRows = await this.db
.select({ total: count() })
.from(attendances)
.where(where);
const totalRow = totalRows[0];
const rows = await this.selectWithRelations()
.where(where)
.orderBy(
...toOrderClauses(ATTENDANCE_ORDER_COLUMNS, filters, [
{ column: 'date', type: 'DESC' },
]),
)
.limit(filters.limit)
.offset(filters.offset);
return {
data: rows.map((row) => this.toDomain(row)),
total: Number(totalRow?.total ?? 0),
};
}
async findById(id: string): Promise<Attendance | null> {
const rows = await this.selectWithRelations()
.where(eq(attendances.id, id))
.limit(1);
const row = rows[0];
return row ? this.toDomain(row) : null;
}
async findOpenByEmployeeId(employeeId: string): Promise<Attendance | null> {
const rows = await this.selectWithRelations()
.where(
and(
eq(attendances.employeeId, employeeId),
isNull(attendances.checkOutAt),
),
)
.limit(1);
const row = rows[0];
return row ? this.toDomain(row) : null;
}
async create(input: CreateAttendanceInput): Promise<Attendance> {
const now = DateTime.fromUnixMs(Date.now());
try {
const inserted = await this.db
.insert(attendances)
.values({
employeeId: input.employeeId,
branchId: input.branchId,
date: input.date.value,
checkInAt: input.checkInAt.value,
checkInMethod: input.checkInMethod,
checkInLatitude: input.checkInLatitude,
checkInLongitude: input.checkInLongitude,
checkInPhotoUrl: input.checkInPhotoUrl,
checkInDistanceMeters: input.checkInDistanceMeters,
status: Status.create('active').value,
createdAt: now.value,
updatedAt: now.value,
createdBy: input.userId,
updatedBy: input.userId,
})
.returning();
const created = await this.findById(inserted[0].id);
if (!created) {
throw new NotFoundException('Attendance not found');
}
return created;
} catch (error) {
this.rethrowUniqueViolation(error);
}
}
async checkOut(
id: string,
input: CheckOutAttendanceInput,
): Promise<Attendance> {
const existing = await this.findById(id);
if (!existing) {
throw new NotFoundException('Attendance not found');
}
if (existing.checkOutAt) {
throw new ConflictException('Attendance is already checked out');
}
const now = DateTime.fromUnixMs(Date.now());
await this.db
.update(attendances)
.set({
checkOutAt: input.checkOutAt.value,
checkOutMethod: input.checkOutMethod,
checkOutLatitude: input.checkOutLatitude,
checkOutLongitude: input.checkOutLongitude,
checkOutPhotoUrl: input.checkOutPhotoUrl,
checkOutDistanceMeters: input.checkOutDistanceMeters,
updatedAt: now.value,
updatedBy: input.userId,
})
.where(eq(attendances.id, id));
const updated = await this.findById(id);
if (!updated) {
throw new NotFoundException('Attendance not found');
}
return updated;
}
async updateStatus(
id: string,
status: Status,
userId: string,
): Promise<Attendance> {
const now = DateTime.fromUnixMs(Date.now());
const updated = await this.db
.update(attendances)
.set({
status: status.value,
updatedAt: now.value,
updatedBy: userId,
})
.where(eq(attendances.id, id))
.returning({ id: attendances.id });
if (updated.length === 0) {
throw new NotFoundException('Attendance not found');
}
const row = await this.findById(id);
if (!row) {
throw new NotFoundException('Attendance not found');
}
return row;
}
async bulkUpdateStatus(
ids: string[],
status: Status,
userId: string,
): Promise<number> {
if (ids.length === 0) {
return 0;
}
const now = DateTime.fromUnixMs(Date.now());
const rows = await this.db
.update(attendances)
.set({
status: status.value,
updatedAt: now.value,
updatedBy: userId,
})
.where(inArray(attendances.id, ids))
.returning({ id: attendances.id });
return rows.length;
}
async delete(id: string): Promise<void> {
const deleted = await this.db
.delete(attendances)
.where(eq(attendances.id, id))
.returning({ id: attendances.id });
if (deleted.length === 0) {
throw new NotFoundException('Attendance not found');
}
}
async bulkDelete(ids: string[]): Promise<number> {
if (ids.length === 0) {
return 0;
}
const deleted = await this.db
.delete(attendances)
.where(inArray(attendances.id, ids))
.returning({ id: attendances.id });
return deleted.length;
}
private selectWithRelations() {
return this.db
.select({
attendance: attendances,
employee: employees,
branch: branches,
division: divisions,
createdByUser: createdByUsers,
updatedByUser: updatedByUsers,
})
.from(attendances)
.innerJoin(employees, eq(attendances.employeeId, employees.id))
.innerJoin(branches, eq(attendances.branchId, branches.id))
.leftJoin(divisions, eq(branches.divisionId, divisions.id))
.leftJoin(createdByUsers, eq(attendances.createdBy, createdByUsers.id))
.leftJoin(updatedByUsers, eq(attendances.updatedBy, updatedByUsers.id));
}
private buildListWhere(filters: ListAttendancesFilters): SQL | undefined {
const parts: SQL[] = [];
if (filters.employeeId) {
parts.push(eq(attendances.employeeId, filters.employeeId));
}
if (filters.branchId) {
parts.push(eq(attendances.branchId, filters.branchId));
}
if (filters.date !== undefined) {
parts.push(eq(attendances.date, filters.date));
}
if (filters.status) {
parts.push(eq(attendances.status, filters.status));
}
if (filters.search) {
const search = or(
ilike(employees.code, `%${filters.search}%`),
ilike(employees.name, `%${filters.search}%`),
ilike(branches.code, `%${filters.search}%`),
ilike(branches.name, `%${filters.search}%`),
);
if (search) {
parts.push(search);
}
}
if (parts.length === 0) {
return undefined;
}
return parts.length === 1 ? parts[0] : and(...parts);
}
private toDomain(row: AttendanceJoinedRow): Attendance {
const attendance = row.attendance;
return {
id: attendance.id,
employeeId: attendance.employeeId,
branchId: attendance.branchId,
date: DateTime.fromUnixMs(attendance.date),
checkInAt: DateTime.fromUnixMs(attendance.checkInAt),
checkInMethod: attendance.checkInMethod as CheckInMethod,
checkInLatitude: attendance.checkInLatitude,
checkInLongitude: attendance.checkInLongitude,
checkInPhotoUrl: attendance.checkInPhotoUrl,
checkInDistanceMeters: attendance.checkInDistanceMeters,
checkOutAt: attendance.checkOutAt
? DateTime.fromUnixMs(attendance.checkOutAt)
: null,
checkOutMethod: attendance.checkOutMethod as CheckInMethod | null,
checkOutLatitude: attendance.checkOutLatitude,
checkOutLongitude: attendance.checkOutLongitude,
checkOutPhotoUrl: attendance.checkOutPhotoUrl,
checkOutDistanceMeters: attendance.checkOutDistanceMeters,
status: Status.create(attendance.status),
createdAt: DateTime.fromUnixMs(attendance.createdAt),
updatedAt: DateTime.fromUnixMs(attendance.updatedAt),
createdBy: attendance.createdBy,
updatedBy: attendance.updatedBy,
employee: {
id: row.employee.id,
code: row.employee.code,
name: row.employee.name,
},
branch: {
id: row.branch.id,
code: row.branch.code,
name: row.branch.name,
division: row.division
? {
id: row.division.id,
code: row.division.code,
name: row.division.name,
}
: null,
},
createdByUser: row.createdByUser
? { id: row.createdByUser.id, username: row.createdByUser.username }
: null,
updatedByUser: row.updatedByUser
? { id: row.updatedByUser.id, username: row.updatedByUser.username }
: null,
};
}
private rethrowUniqueViolation(error: unknown): never {
let current: unknown = error;
for (let i = 0; i < 5; i++) {
if (!current || typeof current !== 'object') {
break;
}
const obj = current as { code?: string; cause?: unknown };
if (obj.code === '23505') {
throw new ConflictException('Attendance already exists for this shift');
}
current = obj.cause;
}
throw error;
}
}
@@ -0,0 +1,262 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { PaginationResponse } from '../../../common/http/response';
import {
pickRelation,
pickUserRelation,
DEFAULT_RELATION_FIELDS,
USER_RELATION_FIELDS,
toListPage,
} from '../../../common/http/response';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
import { BranchesService } from '../../configuration/branches/branches.service';
import { EmployeesService } from '../../configuration/employees/employees.service';
import { CompanySettingsService } from '../settings/company-settings.service';
import {
isCheckInMethod,
verifyBranchCheckIn,
type CheckInPayload,
} from '../shared/check-in-verification';
import { FIELD_ATTENDANCE_PRIVILEGE_KEY } from '../shared/field-purpose';
import type { Attendance } from './attendance';
import type {
AttendanceCheckInDto,
AttendanceCheckOutDto,
AttendanceDto,
ListAttendancesQueryDto,
} from './dto/attendance.dto';
import { AttendancesRepository } from './attendances.repository';
@Injectable()
export class AttendancesService {
constructor(
private readonly attendancesRepository: AttendancesRepository,
private readonly branchesService: BranchesService,
private readonly employeesService: EmployeesService,
private readonly companySettingsService: CompanySettingsService,
) {}
async list(
query: ListAttendancesQueryDto,
): Promise<PaginationResponse<AttendanceDto>> {
const page = toListPage(query);
const { data, total } = await this.attendancesRepository.list({
employeeId: query.employeeId,
branchId: query.branchId,
date: query.date,
status: query.status,
search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
limit: page.limit,
offset: page.offset,
});
return {
data: data.map((item) => this.toItem(item)),
total,
};
}
async findById(id: string): Promise<AttendanceDto> {
const attendance = await this.attendancesRepository.findById(id);
if (!attendance) {
throw new NotFoundException('Attendance not found');
}
return this.toItem(attendance);
}
async findCurrent(userId: string): Promise<AttendanceDto | null> {
const employee = await this.employeesService.requireByUserId(userId);
const attendance = await this.attendancesRepository.findOpenByEmployeeId(
employee.id,
);
return attendance ? this.toItem(attendance) : null;
}
async checkIn(
dto: AttendanceCheckInDto,
userId: string,
): Promise<AttendanceDto> {
const employee = await this.employeesService.requireByUserId(userId);
const open = await this.attendancesRepository.findOpenByEmployeeId(
employee.id,
);
if (open) {
throw new ConflictException('An attendance shift is already open');
}
const branch = await this.branchesService.findById(dto.branchId);
const radiusMeters =
await this.companySettingsService.requireCheckInRadiusMeters();
const payload = this.toPayload(dto);
const verified = verifyBranchCheckIn(
{
code: branch.code,
nfcId: branch.nfcId,
latitude: branch.latitude,
longitude: branch.longitude,
},
payload,
radiusMeters,
);
const now = DateTime.fromUnixMs(Date.now());
const created = await this.attendancesRepository.create({
employeeId: employee.id,
branchId: branch.id,
date: now.startOfDay(),
checkInAt: now,
checkInMethod: verified.method,
checkInLatitude: verified.latitude,
checkInLongitude: verified.longitude,
checkInPhotoUrl: verified.photoUrl,
checkInDistanceMeters: verified.distanceMeters,
userId,
});
return this.toItem(created);
}
async checkOut(
id: string,
dto: AttendanceCheckOutDto,
userId: string,
): Promise<AttendanceDto> {
const employee = await this.employeesService.requireByUserId(userId);
const attendance = await this.attendancesRepository.findById(id);
if (!attendance) {
throw new NotFoundException('Attendance not found');
}
if (attendance.employeeId !== employee.id) {
throw new BadRequestException('Attendance does not belong to this user');
}
if (attendance.checkOutAt) {
throw new ConflictException('Attendance is already checked out');
}
const branch = await this.branchesService.findById(attendance.branchId);
const radiusMeters =
await this.companySettingsService.requireCheckInRadiusMeters();
const payload = this.toPayload(dto);
const verified = verifyBranchCheckIn(
{
code: branch.code,
nfcId: branch.nfcId,
latitude: branch.latitude,
longitude: branch.longitude,
},
payload,
radiusMeters,
);
const updated = await this.attendancesRepository.checkOut(id, {
checkOutAt: DateTime.fromUnixMs(Date.now()),
checkOutMethod: verified.method,
checkOutLatitude: verified.latitude,
checkOutLongitude: verified.longitude,
checkOutPhotoUrl: verified.photoUrl,
checkOutDistanceMeters: verified.distanceMeters,
userId,
});
return this.toItem(updated);
}
async updateStatus(
id: string,
statusRaw: string,
userId: string,
): Promise<AttendanceDto> {
const status = Status.create(statusRaw);
const updated = await this.attendancesRepository.updateStatus(
id,
status,
userId,
);
return this.toItem(updated);
}
async bulkUpdateStatus(
ids: string[],
statusRaw: string,
userId: string,
): Promise<{ updated: number }> {
const status = Status.create(statusRaw);
const updated = await this.attendancesRepository.bulkUpdateStatus(
ids,
status,
userId,
);
return { updated };
}
async delete(id: string): Promise<void> {
await this.attendancesRepository.delete(id);
}
async bulkDelete(ids: string[]): Promise<{ deleted: number }> {
const deleted = await this.attendancesRepository.bulkDelete(ids);
return { deleted };
}
toItem(attendance: Attendance): AttendanceDto {
return {
id: attendance.id,
employee: pickRelation(attendance.employee, DEFAULT_RELATION_FIELDS)!,
branch: pickRelation(attendance.branch, DEFAULT_RELATION_FIELDS)!,
date: attendance.date.value,
checkInAt: attendance.checkInAt.value,
checkInMethod: attendance.checkInMethod,
checkInLatitude: attendance.checkInLatitude,
checkInLongitude: attendance.checkInLongitude,
checkInPhotoUrl: attendance.checkInPhotoUrl,
checkInDistanceMeters: attendance.checkInDistanceMeters,
checkOutAt: attendance.checkOutAt?.value ?? null,
checkOutMethod: attendance.checkOutMethod,
checkOutLatitude: attendance.checkOutLatitude,
checkOutLongitude: attendance.checkOutLongitude,
checkOutPhotoUrl: attendance.checkOutPhotoUrl,
checkOutDistanceMeters: attendance.checkOutDistanceMeters,
status: attendance.status.value,
createdAt: attendance.createdAt.value,
updatedAt: attendance.updatedAt.value,
createdBy: pickUserRelation(
attendance.createdByUser ?? {
id: attendance.createdBy,
username: '',
},
),
updatedBy: pickUserRelation(
attendance.updatedByUser ?? {
id: attendance.updatedBy,
username: '',
},
),
};
}
static privilegeKey(): string {
return FIELD_ATTENDANCE_PRIVILEGE_KEY;
}
private toPayload(
dto: AttendanceCheckInDto | AttendanceCheckOutDto,
): CheckInPayload {
if (!isCheckInMethod(dto.method)) {
throw new BadRequestException('Invalid check-in method');
}
return {
method: dto.method,
nfcId: dto.nfcId,
qrCode: dto.qrCode,
latitude: dto.latitude,
longitude: dto.longitude,
photoUrl: dto.photoUrl,
};
}
}
@@ -0,0 +1,216 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayNotEmpty,
IsArray,
IsIn,
IsLatitude,
IsLongitude,
IsNumber,
IsOptional,
IsString,
IsUrl,
IsUUID,
ValidateIf,
} from 'class-validator';
import { CORE_STATUSES } from '../../../../common/value-objects/status/status';
import { PaginationQueryDto } from '../../../../common/http/response';
import { CHECK_IN_METHODS } from '../../shared/check-in-verification';
export class AttendanceCheckInDto {
@ApiProperty({ format: 'uuid' })
@IsUUID('4')
branchId!: string;
@ApiProperty({ enum: CHECK_IN_METHODS })
@IsIn([...CHECK_IN_METHODS])
method!: string;
@ApiPropertyOptional()
@ValidateIf((dto: AttendanceCheckInDto) => dto.method === 'nfc')
@IsString()
nfcId?: string;
@ApiPropertyOptional()
@ValidateIf((dto: AttendanceCheckInDto) => dto.method === 'qr')
@IsString()
qrCode?: string;
@ApiProperty()
@IsLatitude()
latitude!: number;
@ApiProperty()
@IsLongitude()
longitude!: number;
@ApiPropertyOptional()
@IsOptional()
@IsUrl({ require_protocol: true, protocols: ['https'] })
photoUrl?: string;
}
export class AttendanceCheckOutDto {
@ApiProperty({ enum: CHECK_IN_METHODS })
@IsIn([...CHECK_IN_METHODS])
method!: string;
@ApiPropertyOptional()
@ValidateIf((dto: AttendanceCheckOutDto) => dto.method === 'nfc')
@IsString()
nfcId?: string;
@ApiPropertyOptional()
@ValidateIf((dto: AttendanceCheckOutDto) => dto.method === 'qr')
@IsString()
qrCode?: string;
@ApiProperty()
@IsLatitude()
latitude!: number;
@ApiProperty()
@IsLongitude()
longitude!: number;
@ApiPropertyOptional()
@IsOptional()
@IsUrl({ require_protocol: true, protocols: ['https'] })
photoUrl?: string;
}
export class UpdateAttendanceStatusDto {
@ApiProperty({ enum: CORE_STATUSES })
@IsIn([...CORE_STATUSES])
status!: string;
}
export class BulkIdsDto {
@ApiProperty({ type: [String], format: 'uuid' })
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
ids!: string[];
}
export class BulkStatusDto {
@ApiProperty({ type: [String], format: 'uuid' })
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
ids!: string[];
@ApiProperty({ enum: CORE_STATUSES })
@IsIn([...CORE_STATUSES])
status!: string;
}
export class ListAttendancesQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID('4')
employeeId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID('4')
branchId?: string;
@ApiPropertyOptional({ description: 'Unix ms start of calendar day' })
@IsOptional()
@IsNumber()
date?: number;
@ApiPropertyOptional({ enum: CORE_STATUSES })
@IsOptional()
@IsIn([...CORE_STATUSES])
status?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
search?: string;
}
class RelationDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty()
code!: string;
@ApiProperty()
name!: string;
}
class UserRelationDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty()
username!: string;
}
export class AttendanceDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ type: RelationDto })
employee!: RelationDto;
@ApiProperty({ type: RelationDto })
branch!: RelationDto;
@ApiProperty()
date!: number;
@ApiProperty()
checkInAt!: number;
@ApiProperty({ enum: CHECK_IN_METHODS })
checkInMethod!: string;
@ApiProperty()
checkInLatitude!: number;
@ApiProperty()
checkInLongitude!: number;
@ApiProperty({ nullable: true })
checkInPhotoUrl!: string | null;
@ApiProperty({ nullable: true })
checkInDistanceMeters!: number | null;
@ApiProperty({ nullable: true })
checkOutAt!: number | null;
@ApiProperty({ enum: CHECK_IN_METHODS, nullable: true })
checkOutMethod!: string | null;
@ApiProperty({ nullable: true })
checkOutLatitude!: number | null;
@ApiProperty({ nullable: true })
checkOutLongitude!: number | null;
@ApiProperty({ nullable: true })
checkOutPhotoUrl!: string | null;
@ApiProperty({ nullable: true })
checkOutDistanceMeters!: number | null;
@ApiProperty()
status!: string;
@ApiProperty()
createdAt!: number;
@ApiProperty()
updatedAt!: number;
@ApiProperty({ type: UserRelationDto })
createdBy!: UserRelationDto;
@ApiProperty({ type: UserRelationDto })
updatedBy!: UserRelationDto;
}
+23 -1
View File
@@ -5,6 +5,10 @@ import { EmployeesModule } from '../configuration/employees/employees.module';
import { PrivilegesModule } from '../privileges/privileges.module';
import { PackingSlipsModule } from '../sales/packing-slips/packing-slips.module';
import { SalesInvoicesModule } from '../sales/sales-invoices/sales-invoices.module';
import { AttendancesReadController } from './attendances/attendances-read.controller';
import { AttendancesWriteController } from './attendances/attendances-write.controller';
import { AttendancesRepository } from './attendances/attendances.repository';
import { AttendancesService } from './attendances/attendances.service';
import { CyclesReadController } from './cycles/cycles-read.controller';
import { CyclesWriteController } from './cycles/cycles-write.controller';
import { CyclesRepository } from './cycles/cycles.repository';
@@ -16,6 +20,10 @@ import { PlansService } from './plans/plans.service';
import { CompanySettingsController } from './settings/company-settings.controller';
import { CompanySettingsRepository } from './settings/company-settings.repository';
import { CompanySettingsService } from './settings/company-settings.service';
import { VisitsReadController } from './visits/visits-read.controller';
import { VisitsWriteController } from './visits/visits-write.controller';
import { VisitsRepository } from './visits/visits.repository';
import { VisitsService } from './visits/visits.service';
import { FieldPrivilegeGuard } from './shared/field-privilege.guard';
@Module({
@@ -29,20 +37,34 @@ import { FieldPrivilegeGuard } from './shared/field-privilege.guard';
],
controllers: [
CompanySettingsController,
AttendancesReadController,
AttendancesWriteController,
CyclesReadController,
CyclesWriteController,
PlansReadController,
PlansWriteController,
VisitsReadController,
VisitsWriteController,
],
providers: [
FieldPrivilegeGuard,
CompanySettingsRepository,
CompanySettingsService,
AttendancesRepository,
AttendancesService,
CyclesRepository,
CyclesService,
PlansRepository,
PlansService,
VisitsRepository,
VisitsService,
],
exports: [
CompanySettingsService,
AttendancesService,
CyclesService,
PlansService,
VisitsService,
],
exports: [CompanySettingsService, CyclesService, PlansService],
})
export class FieldModule {}
@@ -4,6 +4,7 @@ import { Status } from '../../../common/value-objects/status/status';
export type CompanySetting = {
readonly id: string;
readonly cycleStartDate: DateTime;
readonly checkInRadiusMeters: number;
readonly status: Status;
readonly createdAt: DateTime;
readonly updatedAt: DateTime;
@@ -13,5 +14,6 @@ export type CompanySetting = {
export type UpsertCompanySettingInput = {
readonly cycleStartDate: DateTime;
readonly checkInRadiusMeters?: number;
readonly userId: string;
};
@@ -47,6 +47,6 @@ export class CompanySettingsController {
@Body() dto: UpdateCompanySettingDto,
@CurrentUser('id') userId: string,
): Promise<CompanySettingDto> {
return this.companySettingsService.update(dto.cycleStartDate, userId);
return this.companySettingsService.update(dto, userId);
}
}
@@ -33,6 +33,7 @@ export class CompanySettingsRepository {
.insert(companySettings)
.values({
cycleStartDate: input.cycleStartDate.value,
checkInRadiusMeters: input.checkInRadiusMeters ?? 100,
status: Status.create(Status.DEFAULT).value,
createdAt: now.value,
updatedAt: now.value,
@@ -46,6 +47,9 @@ export class CompanySettingsRepository {
.update(companySettings)
.set({
cycleStartDate: input.cycleStartDate.value,
...(input.checkInRadiusMeters !== undefined
? { checkInRadiusMeters: input.checkInRadiusMeters }
: {}),
updatedAt: now.value,
updatedBy: input.userId,
})
@@ -58,6 +62,7 @@ export class CompanySettingsRepository {
return {
id: row.id,
cycleStartDate: DateTime.fromUnixMs(row.cycleStartDate),
checkInRadiusMeters: row.checkInRadiusMeters,
status: Status.create(row.status),
createdAt: DateTime.fromUnixMs(row.createdAt),
updatedAt: DateTime.fromUnixMs(row.updatedAt),
@@ -16,6 +16,7 @@ describe('CompanySettingsService', () => {
const sample: CompanySetting = {
id: 'set-1',
cycleStartDate: DateTime.create('2026-01-05'),
checkInRadiusMeters: 100,
status: Status.create('draft'),
createdAt: now,
updatedAt: now,
@@ -46,19 +47,29 @@ describe('CompanySettingsService', () => {
repository.find.mockResolvedValue(sample);
const result = await service.get();
expect(result.cycleStartDate).toBe(sample.cycleStartDate.value);
expect(result.checkInRadiusMeters).toBe(100);
});
it('update persists start of day', async () => {
repository.find.mockResolvedValue(sample);
repository.upsert.mockResolvedValue(sample);
await service.update('2026-01-05', 'user-1');
await service.update({ cycleStartDate: '2026-01-05' }, 'user-1');
const arg = repository.upsert.mock.calls[0][0];
expect(arg.cycleStartDate.equals(DateTime.create('2026-01-05'))).toBe(true);
expect(arg.userId).toBe('user-1');
});
it('update rejects invalid dates', async () => {
await expect(service.update('not-a-date', 'user-1')).rejects.toBeInstanceOf(
BadRequestException,
it('update rejects invalid check-in radius', async () => {
repository.find.mockResolvedValue(sample);
await expect(
service.update({ checkInRadiusMeters: 0 }, 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('update rejects when settings are missing and no cycle date provided', async () => {
repository.find.mockResolvedValue(null);
await expect(service.update({}, 'user-1')).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
@@ -23,17 +23,41 @@ export class CompanySettingsService {
}
async update(
cycleStartDateRaw: string,
input: {
cycleStartDate?: string;
checkInRadiusMeters?: number;
},
userId: string,
): Promise<ReturnType<CompanySettingsService['toItem']>> {
const cycleStartDate = this.assertDate(cycleStartDateRaw).startOfDay();
const existing = await this.companySettingsRepository.find();
const cycleStartDate = input.cycleStartDate
? this.assertDate(input.cycleStartDate).startOfDay()
: existing?.cycleStartDate;
if (!cycleStartDate) {
throw new NotFoundException('Settings not configured');
}
if (
input.checkInRadiusMeters !== undefined &&
(input.checkInRadiusMeters < 1 || input.checkInRadiusMeters > 10_000)
) {
throw new BadRequestException('Invalid check-in radius');
}
const saved = await this.companySettingsRepository.upsert({
cycleStartDate,
checkInRadiusMeters: input.checkInRadiusMeters,
userId,
});
return this.toItem(saved);
}
async requireCheckInRadiusMeters(): Promise<number> {
const setting = await this.companySettingsRepository.find();
if (!setting) {
throw new NotFoundException('Settings not configured');
}
return setting.checkInRadiusMeters;
}
async requireCycleStartDate(): Promise<DateTime> {
const setting = await this.companySettingsRepository.find();
if (!setting) {
@@ -46,6 +70,7 @@ export class CompanySettingsService {
return {
id: setting.id,
cycleStartDate: setting.cycleStartDate.value,
checkInRadiusMeters: setting.checkInRadiusMeters,
status: setting.status.value,
createdAt: setting.createdAt.value,
updatedAt: setting.updatedAt.value,
@@ -1,14 +1,30 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, Matches } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Matches,
Max,
Min,
} from 'class-validator';
export class UpdateCompanySettingDto {
@ApiProperty({ example: '2026-01-05', description: 'YYYY-MM-DD' })
@ApiPropertyOptional({ example: '2026-01-05', description: 'YYYY-MM-DD' })
@IsOptional()
@IsString()
@IsNotEmpty()
@Matches(/^\d{4}-\d{2}-\d{2}$/, {
message: 'cycleStartDate must be a calendar date',
})
cycleStartDate!: string;
cycleStartDate?: string;
@ApiPropertyOptional({ example: 100, minimum: 1, maximum: 10000 })
@IsOptional()
@IsInt()
@Min(1)
@Max(10_000)
checkInRadiusMeters?: number;
}
export class CompanySettingDto {
@@ -18,6 +34,9 @@ export class CompanySettingDto {
@ApiProperty({ description: 'Unix ms start of the cycle-start calendar day' })
cycleStartDate!: number;
@ApiProperty({ example: 100 })
checkInRadiusMeters!: number;
@ApiProperty()
status!: string;
@@ -0,0 +1,62 @@
import {
verifyBranchCheckIn,
verifyCustomerCheckIn,
} from './check-in-verification';
import { haversineDistanceMeters } from './geo-distance';
describe('geo-distance', () => {
it('returns zero for identical coordinates', () => {
expect(haversineDistanceMeters(-6.2, 106.8, -6.2, 106.8)).toBe(0);
});
});
describe('check-in-verification', () => {
const target = {
code: 'BR001',
nfcId: 'nfc-123',
latitude: -6.2,
longitude: 106.8,
};
it('accepts matching NFC tag', () => {
const result = verifyBranchCheckIn(
target,
{
method: 'nfc',
nfcId: 'nfc-123',
latitude: -6.2,
longitude: 106.8,
},
100,
);
expect(result.method).toBe('nfc');
});
it('accepts matching QR code', () => {
const result = verifyBranchCheckIn(
target,
{
method: 'qr',
qrCode: 'BR001',
latitude: -6.2,
longitude: 106.8,
},
100,
);
expect(result.method).toBe('qr');
});
it('rejects GPS when too far', () => {
expect(() =>
verifyCustomerCheckIn(
target,
{
method: 'gps',
latitude: -7,
longitude: 107.5,
},
100,
),
).toThrow('Too far from the customer location');
});
});
@@ -0,0 +1,135 @@
import { BadRequestException } from '@nestjs/common';
import { haversineDistanceMeters } from './geo-distance';
export const CHECK_IN_METHODS = ['nfc', 'qr', 'gps'] as const;
export type CheckInMethod = (typeof CHECK_IN_METHODS)[number];
export function isCheckInMethod(raw: string): raw is CheckInMethod {
return (CHECK_IN_METHODS as readonly string[]).includes(raw);
}
export type CheckInPayload = {
readonly method: CheckInMethod;
readonly nfcId?: string;
readonly qrCode?: string;
readonly latitude: number;
readonly longitude: number;
readonly photoUrl?: string;
};
export type CheckInVerificationResult = {
readonly latitude: number;
readonly longitude: number;
readonly distanceMeters: number | null;
readonly method: CheckInMethod;
readonly photoUrl: string | null;
};
export type BranchCheckInTarget = {
readonly code: string;
readonly nfcId: string | null;
readonly latitude: number | null;
readonly longitude: number | null;
};
export type CustomerCheckInTarget = {
readonly code: string;
readonly nfcId: string | null;
readonly latitude: number | null;
readonly longitude: number | null;
};
export function verifyBranchCheckIn(
target: BranchCheckInTarget,
payload: CheckInPayload,
radiusMeters: number,
): CheckInVerificationResult {
return verifyTargetCheckIn(
target,
payload,
radiusMeters,
'NFC tag does not match this branch',
'QR code does not match this branch',
'Branch location is not configured',
'Too far from the branch location',
);
}
export function verifyCustomerCheckIn(
target: CustomerCheckInTarget,
payload: CheckInPayload,
radiusMeters: number,
): CheckInVerificationResult {
return verifyTargetCheckIn(
target,
payload,
radiusMeters,
'NFC tag does not match this customer',
'QR code does not match this customer',
'Customer location is not configured',
'Too far from the customer location',
);
}
function verifyTargetCheckIn(
target: BranchCheckInTarget | CustomerCheckInTarget,
payload: CheckInPayload,
radiusMeters: number,
nfcMismatchMessage: string,
qrMismatchMessage: string,
locationMissingMessage: string,
tooFarMessage: string,
): CheckInVerificationResult {
if (!isCheckInMethod(payload.method)) {
throw new BadRequestException('Invalid check-in method');
}
let distanceMeters: number | null = null;
if (payload.method === 'nfc') {
if (!payload.nfcId?.trim()) {
throw new BadRequestException('NFC tag is required');
}
if (!target.nfcId || payload.nfcId.trim() !== target.nfcId) {
throw new BadRequestException(nfcMismatchMessage);
}
} else if (payload.method === 'qr') {
if (!payload.qrCode?.trim()) {
throw new BadRequestException('QR code is required');
}
if (payload.qrCode.trim() !== target.code) {
throw new BadRequestException(qrMismatchMessage);
}
} else {
if (target.latitude == null || target.longitude == null) {
throw new BadRequestException(locationMissingMessage);
}
distanceMeters = haversineDistanceMeters(
payload.latitude,
payload.longitude,
target.latitude,
target.longitude,
);
if (distanceMeters > radiusMeters) {
throw new BadRequestException(tooFarMessage);
}
}
return {
latitude: payload.latitude,
longitude: payload.longitude,
distanceMeters:
distanceMeters == null
? target.latitude != null && target.longitude != null
? haversineDistanceMeters(
payload.latitude,
payload.longitude,
target.latitude,
target.longitude,
)
: null
: distanceMeters,
method: payload.method,
photoUrl: payload.photoUrl?.trim() ? payload.photoUrl.trim() : null,
};
}
@@ -27,6 +27,8 @@ export const SALES_PLAN_PRIVILEGE_KEY = 'SALES.PLAN';
export const LOGISTICS_CYCLE_PRIVILEGE_KEY = 'LOGISTICS.CYCLE';
export const LOGISTICS_PLAN_PRIVILEGE_KEY = 'LOGISTICS.PLAN';
export const SETTINGS_PRIVILEGE_KEY = 'CONFIGURATION.SETTING';
export const FIELD_ATTENDANCE_PRIVILEGE_KEY = 'FIELD.ATTENDANCE';
export const FIELD_VISIT_PRIVILEGE_KEY = 'FIELD.VISIT';
export type FieldResource = 'cycle' | 'plan';
+21
View File
@@ -0,0 +1,21 @@
/**
* Haversine distance between two WGS-84 coordinates in meters.
*/
export function haversineDistanceMeters(
lat1: number,
lon1: number,
lat2: number,
lon2: number,
): number {
const earthRadiusMeters = 6_371_000;
const toRadians = (degrees: number) => (degrees * Math.PI) / 180;
const dLat = toRadians(lat2 - lat1);
const dLon = toRadians(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRadians(lat1)) *
Math.cos(toRadians(lat2)) *
Math.sin(dLon / 2) ** 2;
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return earthRadiusMeters * c;
}
+230
View File
@@ -0,0 +1,230 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayNotEmpty,
IsArray,
IsIn,
IsLatitude,
IsLongitude,
IsNumber,
IsOptional,
IsString,
IsUrl,
IsUUID,
ValidateIf,
} from 'class-validator';
import { PaginationQueryDto } from '../../../../common/http/response';
import { CORE_STATUSES } from '../../../../common/value-objects/status/status';
import { CHECK_IN_METHODS } from '../../shared/check-in-verification';
export class VisitCheckInDto {
@ApiProperty({ format: 'uuid' })
@IsUUID('4')
customerId!: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID('4')
planId?: string;
@ApiProperty({ enum: CHECK_IN_METHODS })
@IsIn([...CHECK_IN_METHODS])
method!: string;
@ApiPropertyOptional()
@ValidateIf((dto: VisitCheckInDto) => dto.method === 'nfc')
@IsString()
nfcId?: string;
@ApiPropertyOptional()
@ValidateIf((dto: VisitCheckInDto) => dto.method === 'qr')
@IsString()
qrCode?: string;
@ApiProperty()
@IsLatitude()
latitude!: number;
@ApiProperty()
@IsLongitude()
longitude!: number;
@ApiPropertyOptional()
@IsOptional()
@IsUrl({ require_protocol: true, protocols: ['https'] })
photoUrl?: string;
}
export class VisitCheckOutDto {
@ApiProperty({ enum: CHECK_IN_METHODS })
@IsIn([...CHECK_IN_METHODS])
method!: string;
@ApiPropertyOptional()
@ValidateIf((dto: VisitCheckOutDto) => dto.method === 'nfc')
@IsString()
nfcId?: string;
@ApiPropertyOptional()
@ValidateIf((dto: VisitCheckOutDto) => dto.method === 'qr')
@IsString()
qrCode?: string;
@ApiProperty()
@IsLatitude()
latitude!: number;
@ApiProperty()
@IsLongitude()
longitude!: number;
@ApiPropertyOptional()
@IsOptional()
@IsUrl({ require_protocol: true, protocols: ['https'] })
photoUrl?: string;
}
export class UpdateVisitStatusDto {
@ApiProperty({ enum: CORE_STATUSES })
@IsIn([...CORE_STATUSES])
status!: string;
}
export class BulkIdsDto {
@ApiProperty({ type: [String], format: 'uuid' })
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
ids!: string[];
}
export class BulkStatusDto {
@ApiProperty({ type: [String], format: 'uuid' })
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
ids!: string[];
@ApiProperty({ enum: CORE_STATUSES })
@IsIn([...CORE_STATUSES])
status!: string;
}
export class ListVisitsQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID('4')
employeeId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID('4')
customerId?: string;
@ApiPropertyOptional({ description: 'Unix ms start of calendar day' })
@IsOptional()
@IsNumber()
date?: number;
@ApiPropertyOptional({ enum: CORE_STATUSES })
@IsOptional()
@IsIn([...CORE_STATUSES])
status?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
search?: string;
}
class RelationDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty()
code!: string;
@ApiProperty()
name!: string;
}
class UserRelationDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty()
username!: string;
}
export class VisitDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ type: RelationDto })
employee!: RelationDto;
@ApiProperty({ type: RelationDto })
customer!: RelationDto;
@ApiProperty({ format: 'uuid', nullable: true })
attendanceId!: string | null;
@ApiProperty({ format: 'uuid', nullable: true })
planId!: string | null;
@ApiProperty({ format: 'uuid', nullable: true })
planDestinationId!: string | null;
@ApiProperty()
date!: number;
@ApiProperty()
checkInAt!: number;
@ApiProperty({ enum: CHECK_IN_METHODS })
checkInMethod!: string;
@ApiProperty()
checkInLatitude!: number;
@ApiProperty()
checkInLongitude!: number;
@ApiProperty({ nullable: true })
checkInPhotoUrl!: string | null;
@ApiProperty({ nullable: true })
checkInDistanceMeters!: number | null;
@ApiProperty({ nullable: true })
checkOutAt!: number | null;
@ApiProperty({ enum: CHECK_IN_METHODS, nullable: true })
checkOutMethod!: string | null;
@ApiProperty({ nullable: true })
checkOutLatitude!: number | null;
@ApiProperty({ nullable: true })
checkOutLongitude!: number | null;
@ApiProperty({ nullable: true })
checkOutPhotoUrl!: string | null;
@ApiProperty({ nullable: true })
checkOutDistanceMeters!: number | null;
@ApiProperty()
status!: string;
@ApiProperty()
createdAt!: number;
@ApiProperty()
updatedAt!: number;
@ApiProperty({ type: UserRelationDto })
createdBy!: UserRelationDto;
@ApiProperty({ type: UserRelationDto })
updatedBy!: UserRelationDto;
}
+72
View File
@@ -0,0 +1,72 @@
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
import type { CheckInMethod } from '../shared/check-in-verification';
export type Visit = {
readonly id: string;
readonly employeeId: string;
readonly customerId: string;
readonly attendanceId: string | null;
readonly planId: string | null;
readonly planDestinationId: string | null;
readonly date: DateTime;
readonly checkInAt: DateTime;
readonly checkInMethod: CheckInMethod;
readonly checkInLatitude: number;
readonly checkInLongitude: number;
readonly checkInPhotoUrl: string | null;
readonly checkInDistanceMeters: number | null;
readonly checkOutAt: DateTime | null;
readonly checkOutMethod: CheckInMethod | null;
readonly checkOutLatitude: number | null;
readonly checkOutLongitude: number | null;
readonly checkOutPhotoUrl: string | null;
readonly checkOutDistanceMeters: number | null;
readonly status: Status;
readonly createdAt: DateTime;
readonly updatedAt: DateTime;
readonly createdBy: string;
readonly updatedBy: string;
readonly employee: { id: string; code: string; name: string } | null;
readonly customer: { id: string; code: string; name: string } | null;
readonly createdByUser: { id: string; username: string } | null;
readonly updatedByUser: { id: string; username: string } | null;
};
export type ListVisitsFilters = {
readonly employeeId?: string;
readonly customerId?: string;
readonly date?: number;
readonly status?: string;
readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly limit: number;
readonly offset: number;
};
export type CreateVisitInput = {
readonly employeeId: string;
readonly customerId: string;
readonly attendanceId: string | null;
readonly planId: string | null;
readonly planDestinationId: string | null;
readonly date: DateTime;
readonly checkInAt: DateTime;
readonly checkInMethod: CheckInMethod;
readonly checkInLatitude: number;
readonly checkInLongitude: number;
readonly checkInPhotoUrl: string | null;
readonly checkInDistanceMeters: number | null;
readonly userId: string;
};
export type CheckOutVisitInput = {
readonly checkOutAt: DateTime;
readonly checkOutMethod: CheckInMethod;
readonly checkOutLatitude: number;
readonly checkOutLongitude: number;
readonly checkOutPhotoUrl: string | null;
readonly checkOutDistanceMeters: number | null;
readonly userId: string;
};
@@ -0,0 +1,74 @@
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
import {
ApiBearerAuth,
ApiForbiddenResponse,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
import {
Pagination,
type PaginationResponse,
PaginationMetaDto,
} from '../../../common/http/response';
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
import { FIELD_VISIT_PRIVILEGE_KEY } from '../shared/field-purpose';
import { ListVisitsQueryDto, VisitDto } from './dto/visit.dto';
import { VisitsService } from './visits.service';
@ApiTags('visits')
@ApiBearerAuth(BEARER_AUTH_NAME)
@Controller('visits')
export class VisitsReadController {
constructor(private readonly visitsService: VisitsService) {}
@Get('current')
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'view')
@ApiOperation({ summary: 'Get the open visit for the current user' })
@ApiOkResponse({ type: VisitDto })
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
current(@CurrentUser('id') userId: string): Promise<VisitDto | null> {
return this.visitsService.findCurrent(userId);
}
@Get()
@Pagination()
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'view')
@ApiOperation({ summary: 'List visits' })
@ApiOkResponse({
schema: {
properties: {
data: {
type: 'array',
items: { $ref: '#/components/schemas/VisitDto' },
},
meta: { $ref: '#/components/schemas/PaginationMetaDto' },
},
},
})
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
list(
@Query() query: ListVisitsQueryDto,
): Promise<PaginationResponse<VisitDto>> {
return this.visitsService.list(query);
}
@Get(':id')
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'view')
@ApiOperation({ summary: 'Get visit detail' })
@ApiOkResponse({ type: VisitDto })
@ApiNotFoundResponse()
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<VisitDto> {
return this.visitsService.findById(id);
}
}
void PaginationMetaDto;
@@ -0,0 +1,112 @@
import {
Body,
Controller,
Delete,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiCreatedResponse,
ApiForbiddenResponse,
ApiNoContentResponse,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
import { FIELD_VISIT_PRIVILEGE_KEY } from '../shared/field-purpose';
import {
BulkIdsDto,
BulkStatusDto,
UpdateVisitStatusDto,
VisitCheckInDto,
VisitCheckOutDto,
VisitDto,
} from './dto/visit.dto';
import { VisitsService } from './visits.service';
@ApiTags('visits')
@ApiBearerAuth(BEARER_AUTH_NAME)
@Controller('visits')
export class VisitsWriteController {
constructor(private readonly visitsService: VisitsService) {}
@Post('check-in')
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'create')
@ApiOperation({ summary: 'Check in at a customer location' })
@ApiCreatedResponse({ type: VisitDto })
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
checkIn(
@Body() dto: VisitCheckInDto,
@CurrentUser('id') userId: string,
): Promise<VisitDto> {
return this.visitsService.checkIn(dto, userId);
}
@Post('bulk-delete')
@HttpCode(200)
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'delete')
@ApiOperation({ summary: 'Bulk delete visits' })
@ApiOkResponse({ schema: { properties: { deleted: { type: 'number' } } } })
bulkDelete(@Body() dto: BulkIdsDto): Promise<{ deleted: number }> {
return this.visitsService.bulkDelete(dto.ids);
}
@Post('bulk-status')
@HttpCode(200)
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'update')
@ApiOperation({ summary: 'Bulk update visit status' })
@ApiOkResponse({ schema: { properties: { updated: { type: 'number' } } } })
bulkStatus(
@Body() dto: BulkStatusDto,
@CurrentUser('id') userId: string,
): Promise<{ updated: number }> {
return this.visitsService.bulkUpdateStatus(dto.ids, dto.status, userId);
}
@Post(':id/check-out')
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'update')
@ApiOperation({ summary: 'Check out from a customer visit' })
@ApiOkResponse({ type: VisitDto })
@ApiNotFoundResponse()
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
checkOut(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: VisitCheckOutDto,
@CurrentUser('id') userId: string,
): Promise<VisitDto> {
return this.visitsService.checkOut(id, dto, userId);
}
@Patch(':id/status')
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'update')
@ApiOperation({ summary: 'Update visit status' })
@ApiOkResponse({ type: VisitDto })
updateStatus(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateVisitStatusDto,
@CurrentUser('id') userId: string,
): Promise<VisitDto> {
return this.visitsService.updateStatus(id, dto.status, userId);
}
@Delete(':id')
@HttpCode(204)
@RequirePrivilege(FIELD_VISIT_PRIVILEGE_KEY, 'delete')
@ApiOperation({ summary: 'Delete visit' })
@ApiNoContentResponse()
@ApiNotFoundResponse()
delete(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
return this.visitsService.delete(id);
}
}
@@ -0,0 +1,349 @@
import {
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { and, count, eq, ilike, inArray, isNull, or, SQL } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core';
import { toOrderClauses } from '../../../common/http/response/order-clause';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
import { customers } from '../../../database/customers-table';
import { planDestinations } from '../../../database/plans-table';
import { visits, type VisitRow } from '../../../database/visits-table';
import { employees, users } from '../../../database/schema';
import type {
CheckOutVisitInput,
CreateVisitInput,
ListVisitsFilters,
Visit,
} from './visit';
import type { CheckInMethod } from '../shared/check-in-verification';
const VISIT_ORDER_COLUMNS = {
id: visits.id,
date: visits.date,
status: visits.status,
createdAt: visits.createdAt,
updatedAt: visits.updatedAt,
};
const createdByUsers = alias(users, 'visit_created_by_users');
const updatedByUsers = alias(users, 'visit_updated_by_users');
type VisitJoinedRow = {
visit: VisitRow;
employee: typeof employees.$inferSelect;
customer: typeof customers.$inferSelect;
createdByUser: typeof users.$inferSelect | null;
updatedByUser: typeof users.$inferSelect | null;
};
@Injectable()
export class VisitsRepository {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
async list(
filters: ListVisitsFilters,
): Promise<{ data: Visit[]; total: number }> {
const where = this.buildListWhere(filters);
const totalRows = await this.db
.select({ total: count() })
.from(visits)
.where(where);
const totalRow = totalRows[0];
const rows = await this.selectWithRelations()
.where(where)
.orderBy(
...toOrderClauses(VISIT_ORDER_COLUMNS, filters, [
{ column: 'date', type: 'DESC' },
]),
)
.limit(filters.limit)
.offset(filters.offset);
return {
data: rows.map((row) => this.toDomain(row)),
total: Number(totalRow?.total ?? 0),
};
}
async findById(id: string): Promise<Visit | null> {
const rows = await this.selectWithRelations()
.where(eq(visits.id, id))
.limit(1);
const row = rows[0];
return row ? this.toDomain(row) : null;
}
async findOpenByEmployeeId(employeeId: string): Promise<Visit | null> {
const rows = await this.selectWithRelations()
.where(and(eq(visits.employeeId, employeeId), isNull(visits.checkOutAt)))
.limit(1);
const row = rows[0];
return row ? this.toDomain(row) : null;
}
async findPlanDestinationId(
planId: string,
customerId: string,
): Promise<string | null> {
const rows = await this.db
.select({ id: planDestinations.id })
.from(planDestinations)
.where(
and(
eq(planDestinations.planId, planId),
eq(planDestinations.customerId, customerId),
),
)
.limit(1);
return rows[0]?.id ?? null;
}
async create(input: CreateVisitInput): Promise<Visit> {
const now = DateTime.fromUnixMs(Date.now());
try {
const inserted = await this.db
.insert(visits)
.values({
employeeId: input.employeeId,
customerId: input.customerId,
attendanceId: input.attendanceId,
planId: input.planId,
planDestinationId: input.planDestinationId,
date: input.date.value,
checkInAt: input.checkInAt.value,
checkInMethod: input.checkInMethod,
checkInLatitude: input.checkInLatitude,
checkInLongitude: input.checkInLongitude,
checkInPhotoUrl: input.checkInPhotoUrl,
checkInDistanceMeters: input.checkInDistanceMeters,
status: Status.create('active').value,
createdAt: now.value,
updatedAt: now.value,
createdBy: input.userId,
updatedBy: input.userId,
})
.returning();
const created = await this.findById(inserted[0].id);
if (!created) {
throw new NotFoundException('Visit not found');
}
return created;
} catch (error) {
this.rethrowUniqueViolation(error);
}
}
async checkOut(id: string, input: CheckOutVisitInput): Promise<Visit> {
const existing = await this.findById(id);
if (!existing) {
throw new NotFoundException('Visit not found');
}
if (existing.checkOutAt) {
throw new ConflictException('Visit is already checked out');
}
const now = DateTime.fromUnixMs(Date.now());
await this.db
.update(visits)
.set({
checkOutAt: input.checkOutAt.value,
checkOutMethod: input.checkOutMethod,
checkOutLatitude: input.checkOutLatitude,
checkOutLongitude: input.checkOutLongitude,
checkOutPhotoUrl: input.checkOutPhotoUrl,
checkOutDistanceMeters: input.checkOutDistanceMeters,
updatedAt: now.value,
updatedBy: input.userId,
})
.where(eq(visits.id, id));
const updated = await this.findById(id);
if (!updated) {
throw new NotFoundException('Visit not found');
}
return updated;
}
async updateStatus(
id: string,
status: Status,
userId: string,
): Promise<Visit> {
const now = DateTime.fromUnixMs(Date.now());
const updated = await this.db
.update(visits)
.set({
status: status.value,
updatedAt: now.value,
updatedBy: userId,
})
.where(eq(visits.id, id))
.returning({ id: visits.id });
if (updated.length === 0) {
throw new NotFoundException('Visit not found');
}
const row = await this.findById(id);
if (!row) {
throw new NotFoundException('Visit not found');
}
return row;
}
async bulkUpdateStatus(
ids: string[],
status: Status,
userId: string,
): Promise<number> {
if (ids.length === 0) {
return 0;
}
const now = DateTime.fromUnixMs(Date.now());
const rows = await this.db
.update(visits)
.set({
status: status.value,
updatedAt: now.value,
updatedBy: userId,
})
.where(inArray(visits.id, ids))
.returning({ id: visits.id });
return rows.length;
}
async delete(id: string): Promise<void> {
const deleted = await this.db
.delete(visits)
.where(eq(visits.id, id))
.returning({ id: visits.id });
if (deleted.length === 0) {
throw new NotFoundException('Visit not found');
}
}
async bulkDelete(ids: string[]): Promise<number> {
if (ids.length === 0) {
return 0;
}
const deleted = await this.db
.delete(visits)
.where(inArray(visits.id, ids))
.returning({ id: visits.id });
return deleted.length;
}
private selectWithRelations() {
return this.db
.select({
visit: visits,
employee: employees,
customer: customers,
createdByUser: createdByUsers,
updatedByUser: updatedByUsers,
})
.from(visits)
.innerJoin(employees, eq(visits.employeeId, employees.id))
.innerJoin(customers, eq(visits.customerId, customers.id))
.leftJoin(createdByUsers, eq(visits.createdBy, createdByUsers.id))
.leftJoin(updatedByUsers, eq(visits.updatedBy, updatedByUsers.id));
}
private buildListWhere(filters: ListVisitsFilters): SQL | undefined {
const parts: SQL[] = [];
if (filters.employeeId) {
parts.push(eq(visits.employeeId, filters.employeeId));
}
if (filters.customerId) {
parts.push(eq(visits.customerId, filters.customerId));
}
if (filters.date !== undefined) {
parts.push(eq(visits.date, filters.date));
}
if (filters.status) {
parts.push(eq(visits.status, filters.status));
}
if (filters.search) {
const search = or(
ilike(employees.code, `%${filters.search}%`),
ilike(employees.name, `%${filters.search}%`),
ilike(customers.code, `%${filters.search}%`),
ilike(customers.name, `%${filters.search}%`),
);
if (search) {
parts.push(search);
}
}
if (parts.length === 0) {
return undefined;
}
return parts.length === 1 ? parts[0] : and(...parts);
}
private toDomain(row: VisitJoinedRow): Visit {
const visit = row.visit;
return {
id: visit.id,
employeeId: visit.employeeId,
customerId: visit.customerId,
attendanceId: visit.attendanceId,
planId: visit.planId,
planDestinationId: visit.planDestinationId,
date: DateTime.fromUnixMs(visit.date),
checkInAt: DateTime.fromUnixMs(visit.checkInAt),
checkInMethod: visit.checkInMethod as CheckInMethod,
checkInLatitude: visit.checkInLatitude,
checkInLongitude: visit.checkInLongitude,
checkInPhotoUrl: visit.checkInPhotoUrl,
checkInDistanceMeters: visit.checkInDistanceMeters,
checkOutAt: visit.checkOutAt
? DateTime.fromUnixMs(visit.checkOutAt)
: null,
checkOutMethod: visit.checkOutMethod as CheckInMethod | null,
checkOutLatitude: visit.checkOutLatitude,
checkOutLongitude: visit.checkOutLongitude,
checkOutPhotoUrl: visit.checkOutPhotoUrl,
checkOutDistanceMeters: visit.checkOutDistanceMeters,
status: Status.create(visit.status),
createdAt: DateTime.fromUnixMs(visit.createdAt),
updatedAt: DateTime.fromUnixMs(visit.updatedAt),
createdBy: visit.createdBy,
updatedBy: visit.updatedBy,
employee: {
id: row.employee.id,
code: row.employee.code,
name: row.employee.name,
},
customer: {
id: row.customer.id,
code: row.customer.code,
name: row.customer.name,
},
createdByUser: row.createdByUser
? { id: row.createdByUser.id, username: row.createdByUser.username }
: null,
updatedByUser: row.updatedByUser
? { id: row.updatedByUser.id, username: row.updatedByUser.username }
: null,
};
}
private rethrowUniqueViolation(error: unknown): never {
let current: unknown = error;
for (let i = 0; i < 5; i++) {
if (!current || typeof current !== 'object') {
break;
}
const obj = current as { code?: string; cause?: unknown };
if (obj.code === '23505') {
throw new ConflictException(
'A visit is already open for this employee',
);
}
current = obj.cause;
}
throw error;
}
}
+266
View File
@@ -0,0 +1,266 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { PaginationResponse } from '../../../common/http/response';
import {
pickRelation,
pickUserRelation,
DEFAULT_RELATION_FIELDS,
toListPage,
} from '../../../common/http/response';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
import { CustomersService } from '../../configuration/customers/customers.service';
import { EmployeesService } from '../../configuration/employees/employees.service';
import { AttendancesRepository } from '../attendances/attendances.repository';
import { CompanySettingsService } from '../settings/company-settings.service';
import {
isCheckInMethod,
verifyCustomerCheckIn,
type CheckInPayload,
} from '../shared/check-in-verification';
import { FIELD_VISIT_PRIVILEGE_KEY } from '../shared/field-purpose';
import type { Visit } from './visit';
import type {
ListVisitsQueryDto,
VisitCheckInDto,
VisitCheckOutDto,
VisitDto,
} from './dto/visit.dto';
import { VisitsRepository } from './visits.repository';
@Injectable()
export class VisitsService {
constructor(
private readonly visitsRepository: VisitsRepository,
private readonly attendancesRepository: AttendancesRepository,
private readonly customersService: CustomersService,
private readonly employeesService: EmployeesService,
private readonly companySettingsService: CompanySettingsService,
) {}
async list(query: ListVisitsQueryDto): Promise<PaginationResponse<VisitDto>> {
const page = toListPage(query);
const { data, total } = await this.visitsRepository.list({
employeeId: query.employeeId,
customerId: query.customerId,
date: query.date,
status: query.status,
search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
limit: page.limit,
offset: page.offset,
});
return {
data: data.map((item) => this.toItem(item)),
total,
};
}
async findById(id: string): Promise<VisitDto> {
const visit = await this.visitsRepository.findById(id);
if (!visit) {
throw new NotFoundException('Visit not found');
}
return this.toItem(visit);
}
async findCurrent(userId: string): Promise<VisitDto | null> {
const employee = await this.employeesService.requireByUserId(userId);
const visit = await this.visitsRepository.findOpenByEmployeeId(employee.id);
return visit ? this.toItem(visit) : null;
}
async checkIn(dto: VisitCheckInDto, userId: string): Promise<VisitDto> {
const employee = await this.employeesService.requireByUserId(userId);
const openVisit = await this.visitsRepository.findOpenByEmployeeId(
employee.id,
);
if (openVisit) {
throw new ConflictException('A customer visit is already open');
}
const openAttendance =
await this.attendancesRepository.findOpenByEmployeeId(employee.id);
if (!openAttendance) {
throw new BadRequestException(
'Branch check-in is required before visiting a customer',
);
}
const customer = await this.customersService.findById(dto.customerId);
const radiusMeters =
await this.companySettingsService.requireCheckInRadiusMeters();
const payload = this.toPayload(dto);
const verified = verifyCustomerCheckIn(
{
code: customer.code,
nfcId: customer.nfcId,
latitude: customer.latitude,
longitude: customer.longitude,
},
payload,
radiusMeters,
);
let planDestinationId: string | null = null;
if (dto.planId) {
planDestinationId = await this.visitsRepository.findPlanDestinationId(
dto.planId,
dto.customerId,
);
}
const now = DateTime.fromUnixMs(Date.now());
const created = await this.visitsRepository.create({
employeeId: employee.id,
customerId: dto.customerId,
attendanceId: openAttendance.id,
planId: dto.planId ?? null,
planDestinationId,
date: now.startOfDay(),
checkInAt: now,
checkInMethod: verified.method,
checkInLatitude: verified.latitude,
checkInLongitude: verified.longitude,
checkInPhotoUrl: verified.photoUrl,
checkInDistanceMeters: verified.distanceMeters,
userId,
});
return this.toItem(created);
}
async checkOut(
id: string,
dto: VisitCheckOutDto,
userId: string,
): Promise<VisitDto> {
const employee = await this.employeesService.requireByUserId(userId);
const visit = await this.visitsRepository.findById(id);
if (!visit) {
throw new NotFoundException('Visit not found');
}
if (visit.employeeId !== employee.id) {
throw new BadRequestException('Visit does not belong to this user');
}
if (visit.checkOutAt) {
throw new ConflictException('Visit is already checked out');
}
const customer = await this.customersService.findById(visit.customerId);
const radiusMeters =
await this.companySettingsService.requireCheckInRadiusMeters();
const payload = this.toPayload(dto);
const verified = verifyCustomerCheckIn(
{
code: customer.code,
nfcId: customer.nfcId,
latitude: customer.latitude,
longitude: customer.longitude,
},
payload,
radiusMeters,
);
const updated = await this.visitsRepository.checkOut(id, {
checkOutAt: DateTime.fromUnixMs(Date.now()),
checkOutMethod: verified.method,
checkOutLatitude: verified.latitude,
checkOutLongitude: verified.longitude,
checkOutPhotoUrl: verified.photoUrl,
checkOutDistanceMeters: verified.distanceMeters,
userId,
});
return this.toItem(updated);
}
async updateStatus(
id: string,
statusRaw: string,
userId: string,
): Promise<VisitDto> {
const status = Status.create(statusRaw);
const updated = await this.visitsRepository.updateStatus(
id,
status,
userId,
);
return this.toItem(updated);
}
async bulkUpdateStatus(
ids: string[],
statusRaw: string,
userId: string,
): Promise<{ updated: number }> {
const status = Status.create(statusRaw);
const updated = await this.visitsRepository.bulkUpdateStatus(
ids,
status,
userId,
);
return { updated };
}
async delete(id: string): Promise<void> {
await this.visitsRepository.delete(id);
}
async bulkDelete(ids: string[]): Promise<{ deleted: number }> {
const deleted = await this.visitsRepository.bulkDelete(ids);
return { deleted };
}
toItem(visit: Visit): VisitDto {
return {
id: visit.id,
employee: pickRelation(visit.employee, DEFAULT_RELATION_FIELDS)!,
customer: pickRelation(visit.customer, DEFAULT_RELATION_FIELDS)!,
attendanceId: visit.attendanceId,
planId: visit.planId,
planDestinationId: visit.planDestinationId,
date: visit.date.value,
checkInAt: visit.checkInAt.value,
checkInMethod: visit.checkInMethod,
checkInLatitude: visit.checkInLatitude,
checkInLongitude: visit.checkInLongitude,
checkInPhotoUrl: visit.checkInPhotoUrl,
checkInDistanceMeters: visit.checkInDistanceMeters,
checkOutAt: visit.checkOutAt?.value ?? null,
checkOutMethod: visit.checkOutMethod,
checkOutLatitude: visit.checkOutLatitude,
checkOutLongitude: visit.checkOutLongitude,
checkOutPhotoUrl: visit.checkOutPhotoUrl,
checkOutDistanceMeters: visit.checkOutDistanceMeters,
status: visit.status.value,
createdAt: visit.createdAt.value,
updatedAt: visit.updatedAt.value,
createdBy: pickUserRelation(
visit.createdByUser ?? { id: visit.createdBy, username: '' },
),
updatedBy: pickUserRelation(
visit.updatedByUser ?? { id: visit.updatedBy, username: '' },
),
};
}
private toPayload(dto: VisitCheckInDto | VisitCheckOutDto): CheckInPayload {
if (!isCheckInMethod(dto.method)) {
throw new BadRequestException('Invalid check-in method');
}
return {
method: dto.method,
nfcId: dto.nfcId,
qrCode: dto.qrCode,
latitude: dto.latitude,
longitude: dto.longitude,
photoUrl: dto.photoUrl,
};
}
}
void FIELD_VISIT_PRIVILEGE_KEY;