diff --git a/drizzle/migrations/0017_timeline.sql b/drizzle/migrations/0017_timeline.sql new file mode 100644 index 0000000..5bd38fa --- /dev/null +++ b/drizzle/migrations/0017_timeline.sql @@ -0,0 +1,42 @@ +ALTER TABLE "company_settings" ADD COLUMN "gps_interval_seconds" integer DEFAULT 5 NOT NULL; +--> statement-breakpoint +ALTER TABLE "company_settings" ADD COLUMN "checkout_warning_radius_meters" integer DEFAULT 200 NOT NULL; +--> statement-breakpoint +CREATE TABLE "timeline_footprints" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "employee_id" uuid NOT NULL, + "latitude" double precision NOT NULL, + "longitude" double precision NOT NULL, + "recorded_at" bigint NOT NULL +); +--> statement-breakpoint +CREATE TABLE "timeline_activities" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "employee_id" uuid NOT NULL, + "customer_id" uuid, + "visit_id" uuid, + "type" text NOT NULL, + "source_type" text NOT NULL, + "source_id" uuid NOT NULL, + "latitude" double precision NOT NULL, + "longitude" double precision NOT NULL, + "recorded_at" bigint NOT NULL +); +--> statement-breakpoint +ALTER TABLE "timeline_footprints" ADD CONSTRAINT "timeline_footprints_employee_id_employees_id_fk" FOREIGN KEY ("employee_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "timeline_activities" ADD CONSTRAINT "timeline_activities_employee_id_employees_id_fk" FOREIGN KEY ("employee_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "timeline_activities" ADD CONSTRAINT "timeline_activities_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE set null ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "timeline_activities" ADD CONSTRAINT "timeline_activities_visit_id_visits_id_fk" FOREIGN KEY ("visit_id") REFERENCES "public"."visits"("id") ON DELETE set null ON UPDATE no action; +--> statement-breakpoint +CREATE INDEX "timeline_footprints_employee_recorded_idx" ON "timeline_footprints" USING btree ("employee_id","recorded_at"); +--> statement-breakpoint +CREATE INDEX "timeline_activities_employee_recorded_idx" ON "timeline_activities" USING btree ("employee_id","recorded_at"); +--> statement-breakpoint +CREATE INDEX "timeline_activities_visit_id_idx" ON "timeline_activities" USING btree ("visit_id"); +--> statement-breakpoint +INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES + ('ADMIN.SALES.ACTIVITIES.TIMELINE', 'Sales timeline', 119), + ('MOBILE.SALES.TIMELINE', 'Sales timeline (mobile)', 210); diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index 0c5fc8e..805d883 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -120,6 +120,13 @@ "when": 1787564000000, "tag": "0016_privilege_key_hierarchy", "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1787565000000, + "tag": "0017_timeline", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/database/company-settings-table.ts b/src/database/company-settings-table.ts index 74d8673..3a40743 100644 --- a/src/database/company-settings-table.ts +++ b/src/database/company-settings-table.ts @@ -9,6 +9,10 @@ export const companySettings = pgTable('company_settings', { id: uuid('id').defaultRandom().notNull().primaryKey(), cycleStartDate: bigint('cycle_start_date', { mode: 'number' }).notNull(), checkInRadiusMeters: integer('check_in_radius_meters').notNull().default(100), + gpsIntervalSeconds: integer('gps_interval_seconds').notNull().default(5), + checkoutWarningRadiusMeters: integer('checkout_warning_radius_meters') + .notNull() + .default(200), ...primaryEntityColumns(users), }); diff --git a/src/database/schema.ts b/src/database/schema.ts index 7c7c913..06f969f 100644 --- a/src/database/schema.ts +++ b/src/database/schema.ts @@ -253,6 +253,18 @@ export { type NewAttendanceRow, } from './attendances-table'; export { visits, type VisitRow, type NewVisitRow } from './visits-table'; +export { + timelineFootprints, + type TimelineFootprintRow, + type NewTimelineFootprintRow, +} from './timeline-footprints-table'; +export { + timelineActivities, + TIMELINE_ACTIVITY_TYPES, + type TimelineActivityType, + type TimelineActivityRow, + type NewTimelineActivityRow, +} from './timeline-activities-table'; export { reportBookmarks, type NewReportBookmarkRow, diff --git a/src/database/timeline-activities-table.ts b/src/database/timeline-activities-table.ts new file mode 100644 index 0000000..c497622 --- /dev/null +++ b/src/database/timeline-activities-table.ts @@ -0,0 +1,56 @@ +import { + bigint, + doublePrecision, + index, + pgTable, + text, + uuid, +} from 'drizzle-orm/pg-core'; +import { customers } from './customers-table'; +import { employees } from './employees-table'; +import { visits } from './visits-table'; + +export const TIMELINE_ACTIVITY_TYPES = [ + 'branch_check_in', + 'branch_check_out', + 'customer_check_in', + 'customer_check_out', + 'sales_order_created', + 'sales_request_created', + 'sales_payment_created', + 'customer_created', +] as const; + +export type TimelineActivityType = (typeof TIMELINE_ACTIVITY_TYPES)[number]; + +export const timelineActivities = pgTable( + 'timeline_activities', + { + id: uuid('id').defaultRandom().notNull().primaryKey(), + employeeId: uuid('employee_id') + .notNull() + .references(() => employees.id, { onDelete: 'restrict' }), + customerId: uuid('customer_id').references(() => customers.id, { + onDelete: 'set null', + }), + visitId: uuid('visit_id').references(() => visits.id, { + onDelete: 'set null', + }), + type: text('type').notNull(), + sourceType: text('source_type').notNull(), + sourceId: uuid('source_id').notNull(), + latitude: doublePrecision('latitude').notNull(), + longitude: doublePrecision('longitude').notNull(), + recordedAt: bigint('recorded_at', { mode: 'number' }).notNull(), + }, + (t) => [ + index('timeline_activities_employee_recorded_idx').on( + t.employeeId, + t.recordedAt, + ), + index('timeline_activities_visit_id_idx').on(t.visitId), + ], +); + +export type TimelineActivityRow = typeof timelineActivities.$inferSelect; +export type NewTimelineActivityRow = typeof timelineActivities.$inferInsert; diff --git a/src/database/timeline-footprints-table.ts b/src/database/timeline-footprints-table.ts new file mode 100644 index 0000000..3e264f6 --- /dev/null +++ b/src/database/timeline-footprints-table.ts @@ -0,0 +1,24 @@ +import { bigint, doublePrecision, index, pgTable, uuid } from 'drizzle-orm/pg-core'; +import { employees } from './employees-table'; + +export const timelineFootprints = pgTable( + 'timeline_footprints', + { + id: uuid('id').defaultRandom().notNull().primaryKey(), + employeeId: uuid('employee_id') + .notNull() + .references(() => employees.id, { onDelete: 'restrict' }), + latitude: doublePrecision('latitude').notNull(), + longitude: doublePrecision('longitude').notNull(), + recordedAt: bigint('recorded_at', { mode: 'number' }).notNull(), + }, + (t) => [ + index('timeline_footprints_employee_recorded_idx').on( + t.employeeId, + t.recordedAt, + ), + ], +); + +export type TimelineFootprintRow = typeof timelineFootprints.$inferSelect; +export type NewTimelineFootprintRow = typeof timelineFootprints.$inferInsert; diff --git a/src/modules/configuration/customers/customers.module.ts b/src/modules/configuration/customers/customers.module.ts index a062f80..361626f 100644 --- a/src/modules/configuration/customers/customers.module.ts +++ b/src/modules/configuration/customers/customers.module.ts @@ -1,10 +1,13 @@ import { Module } from '@nestjs/common'; +import { EmployeesModule } from '../employees/employees.module'; +import { TimelineModule } from '../../field/timeline/timeline.module'; import { CustomersReadController } from './customers-read.controller'; import { CustomersWriteController } from './customers-write.controller'; import { CustomersRepository } from './customers.repository'; import { CustomersService } from './customers.service'; @Module({ + imports: [EmployeesModule, TimelineModule], controllers: [CustomersReadController, CustomersWriteController], providers: [CustomersRepository, CustomersService], exports: [CustomersService], diff --git a/src/modules/configuration/customers/customers.service.ts b/src/modules/configuration/customers/customers.service.ts index 6ac3889..0572d8e 100644 --- a/src/modules/configuration/customers/customers.service.ts +++ b/src/modules/configuration/customers/customers.service.ts @@ -29,6 +29,8 @@ import { parseCsvRecord, } from './customer-fields'; import { CustomersRepository } from './customers.repository'; +import { EmployeesService } from '../employees/employees.service'; +import { TimelineActivitiesService } from '../../field/timeline/timeline-activities.service'; export type ListCustomersQuery = { readonly code?: string; @@ -73,7 +75,11 @@ const CSV_REQUIRED_HEADERS = ['code', 'name', 'phone', 'address'] as const; @Injectable() export class CustomersService { - constructor(private readonly customersRepository: CustomersRepository) {} + constructor( + private readonly customersRepository: CustomersRepository, + private readonly employeesService: EmployeesService, + private readonly timelineActivitiesService: TimelineActivitiesService, + ) {} async list( query: ListCustomersQuery, @@ -133,6 +139,16 @@ export class CustomersService { const created = await this.customersRepository.create( this.toCreateInput(input), ); + const employee = await this.employeesService.requireByUserId(input.userId); + await this.timelineActivitiesService.recordIfLocated({ + employeeId: employee.id, + type: 'customer_created', + sourceType: 'customer', + sourceId: created.id, + latitude: created.latitude, + longitude: created.longitude, + customerId: created.id, + }); return this.toDetail(created); } diff --git a/src/modules/field/attendances/attendances.service.ts b/src/modules/field/attendances/attendances.service.ts index e71c99f..3e4ca6d 100644 --- a/src/modules/field/attendances/attendances.service.ts +++ b/src/modules/field/attendances/attendances.service.ts @@ -23,6 +23,7 @@ import { type CheckInPayload, } from '../shared/check-in-verification'; import { FIELD_ATTENDANCE_PRIVILEGE_KEY } from '../shared/field-purpose'; +import { TimelineActivitiesService } from '../timeline/timeline-activities.service'; import type { Attendance } from './attendance'; import type { AttendanceCheckInDto, @@ -39,6 +40,7 @@ export class AttendancesService { private readonly branchesService: BranchesService, private readonly employeesService: EmployeesService, private readonly companySettingsService: CompanySettingsService, + private readonly timelineActivitiesService: TimelineActivitiesService, ) {} async list( @@ -119,6 +121,15 @@ export class AttendancesService { checkInDistanceMeters: verified.distanceMeters, userId, }); + await this.timelineActivitiesService.recordIfLocated({ + employeeId: employee.id, + type: 'branch_check_in', + sourceType: 'attendance', + sourceId: created.id, + latitude: verified.latitude, + longitude: verified.longitude, + recordedAt: now.value, + }); return this.toItem(created); } @@ -164,6 +175,15 @@ export class AttendancesService { checkOutDistanceMeters: verified.distanceMeters, userId, }); + await this.timelineActivitiesService.recordIfLocated({ + employeeId: employee.id, + type: 'branch_check_out', + sourceType: 'attendance', + sourceId: updated.id, + latitude: verified.latitude, + longitude: verified.longitude, + recordedAt: updated.checkOutAt?.value, + }); return this.toItem(updated); } diff --git a/src/modules/field/field.module.ts b/src/modules/field/field.module.ts index 54e7e49..3aa92f7 100644 --- a/src/modules/field/field.module.ts +++ b/src/modules/field/field.module.ts @@ -25,6 +25,7 @@ import { VisitsWriteController } from './visits/visits-write.controller'; import { VisitsRepository } from './visits/visits.repository'; import { VisitsService } from './visits/visits.service'; import { FieldPrivilegeGuard } from './shared/field-privilege.guard'; +import { TimelineModule } from './timeline/timeline.module'; @Module({ imports: [ @@ -34,6 +35,7 @@ import { FieldPrivilegeGuard } from './shared/field-privilege.guard'; CustomersModule, SalesInvoicesModule, PackingSlipsModule, + TimelineModule, ], controllers: [ CompanySettingsController, @@ -65,6 +67,7 @@ import { FieldPrivilegeGuard } from './shared/field-privilege.guard'; CyclesService, PlansService, VisitsService, + TimelineModule, ], }) export class FieldModule {} diff --git a/src/modules/field/settings/company-setting.ts b/src/modules/field/settings/company-setting.ts index 8784215..6e214c9 100644 --- a/src/modules/field/settings/company-setting.ts +++ b/src/modules/field/settings/company-setting.ts @@ -5,6 +5,8 @@ export type CompanySetting = { readonly id: string; readonly cycleStartDate: DateTime; readonly checkInRadiusMeters: number; + readonly gpsIntervalSeconds: number; + readonly checkoutWarningRadiusMeters: number; readonly status: Status; readonly createdAt: DateTime; readonly updatedAt: DateTime; @@ -15,5 +17,7 @@ export type CompanySetting = { export type UpsertCompanySettingInput = { readonly cycleStartDate: DateTime; readonly checkInRadiusMeters?: number; + readonly gpsIntervalSeconds?: number; + readonly checkoutWarningRadiusMeters?: number; readonly userId: string; }; diff --git a/src/modules/field/settings/company-settings.repository.ts b/src/modules/field/settings/company-settings.repository.ts index fa2eccb..f7b1fc1 100644 --- a/src/modules/field/settings/company-settings.repository.ts +++ b/src/modules/field/settings/company-settings.repository.ts @@ -34,6 +34,8 @@ export class CompanySettingsRepository { .values({ cycleStartDate: input.cycleStartDate.value, checkInRadiusMeters: input.checkInRadiusMeters ?? 100, + gpsIntervalSeconds: input.gpsIntervalSeconds ?? 5, + checkoutWarningRadiusMeters: input.checkoutWarningRadiusMeters ?? 200, status: Status.create(Status.DEFAULT).value, createdAt: now.value, updatedAt: now.value, @@ -50,6 +52,12 @@ export class CompanySettingsRepository { ...(input.checkInRadiusMeters !== undefined ? { checkInRadiusMeters: input.checkInRadiusMeters } : {}), + ...(input.gpsIntervalSeconds !== undefined + ? { gpsIntervalSeconds: input.gpsIntervalSeconds } + : {}), + ...(input.checkoutWarningRadiusMeters !== undefined + ? { checkoutWarningRadiusMeters: input.checkoutWarningRadiusMeters } + : {}), updatedAt: now.value, updatedBy: input.userId, }) @@ -63,6 +71,8 @@ export class CompanySettingsRepository { id: row.id, cycleStartDate: DateTime.fromUnixMs(row.cycleStartDate), checkInRadiusMeters: row.checkInRadiusMeters, + gpsIntervalSeconds: row.gpsIntervalSeconds, + checkoutWarningRadiusMeters: row.checkoutWarningRadiusMeters, status: Status.create(row.status), createdAt: DateTime.fromUnixMs(row.createdAt), updatedAt: DateTime.fromUnixMs(row.updatedAt), diff --git a/src/modules/field/settings/company-settings.service.spec.ts b/src/modules/field/settings/company-settings.service.spec.ts index b556755..32cca0f 100644 --- a/src/modules/field/settings/company-settings.service.spec.ts +++ b/src/modules/field/settings/company-settings.service.spec.ts @@ -17,6 +17,8 @@ describe('CompanySettingsService', () => { id: 'set-1', cycleStartDate: DateTime.create('2026-01-05'), checkInRadiusMeters: 100, + gpsIntervalSeconds: 5, + checkoutWarningRadiusMeters: 200, status: Status.create('draft'), createdAt: now, updatedAt: now, @@ -48,6 +50,8 @@ describe('CompanySettingsService', () => { const result = await service.get(); expect(result.cycleStartDate).toBe(sample.cycleStartDate.value); expect(result.checkInRadiusMeters).toBe(100); + expect(result.gpsIntervalSeconds).toBe(5); + expect(result.checkoutWarningRadiusMeters).toBe(200); }); it('update persists start of day', async () => { @@ -66,6 +70,21 @@ describe('CompanySettingsService', () => { ).rejects.toBeInstanceOf(BadRequestException); }); + it('update rejects invalid GPS interval', async () => { + repository.find.mockResolvedValue(sample); + await expect( + service.update({ gpsIntervalSeconds: 2 }, 'user-1'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('requireTimelineConfig returns tracking settings', async () => { + repository.find.mockResolvedValue(sample); + await expect(service.requireTimelineConfig()).resolves.toEqual({ + gpsIntervalSeconds: 5, + checkoutWarningRadiusMeters: 200, + }); + }); + it('update rejects when settings are missing and no cycle date provided', async () => { repository.find.mockResolvedValue(null); await expect(service.update({}, 'user-1')).rejects.toBeInstanceOf( diff --git a/src/modules/field/settings/company-settings.service.ts b/src/modules/field/settings/company-settings.service.ts index e774c52..9f969a3 100644 --- a/src/modules/field/settings/company-settings.service.ts +++ b/src/modules/field/settings/company-settings.service.ts @@ -26,6 +26,8 @@ export class CompanySettingsService { input: { cycleStartDate?: string; checkInRadiusMeters?: number; + gpsIntervalSeconds?: number; + checkoutWarningRadiusMeters?: number; }, userId: string, ): Promise> { @@ -42,9 +44,24 @@ export class CompanySettingsService { ) { throw new BadRequestException('Invalid check-in radius'); } + if ( + input.gpsIntervalSeconds !== undefined && + (input.gpsIntervalSeconds < 5 || input.gpsIntervalSeconds > 300) + ) { + throw new BadRequestException('Invalid GPS interval'); + } + if ( + input.checkoutWarningRadiusMeters !== undefined && + (input.checkoutWarningRadiusMeters < 1 || + input.checkoutWarningRadiusMeters > 10_000) + ) { + throw new BadRequestException('Invalid checkout warning radius'); + } const saved = await this.companySettingsRepository.upsert({ cycleStartDate, checkInRadiusMeters: input.checkInRadiusMeters, + gpsIntervalSeconds: input.gpsIntervalSeconds, + checkoutWarningRadiusMeters: input.checkoutWarningRadiusMeters, userId, }); return this.toItem(saved); @@ -66,11 +83,27 @@ export class CompanySettingsService { return setting.cycleStartDate; } + async requireTimelineConfig(): Promise<{ + gpsIntervalSeconds: number; + checkoutWarningRadiusMeters: number; + }> { + const setting = await this.companySettingsRepository.find(); + if (!setting) { + throw new NotFoundException('Settings not configured'); + } + return { + gpsIntervalSeconds: setting.gpsIntervalSeconds, + checkoutWarningRadiusMeters: setting.checkoutWarningRadiusMeters, + }; + } + toItem(setting: CompanySetting) { return { id: setting.id, cycleStartDate: setting.cycleStartDate.value, checkInRadiusMeters: setting.checkInRadiusMeters, + gpsIntervalSeconds: setting.gpsIntervalSeconds, + checkoutWarningRadiusMeters: setting.checkoutWarningRadiusMeters, status: setting.status.value, createdAt: setting.createdAt.value, updatedAt: setting.updatedAt.value, diff --git a/src/modules/field/settings/dto/company-setting.dto.ts b/src/modules/field/settings/dto/company-setting.dto.ts index 743c0b6..91948a9 100644 --- a/src/modules/field/settings/dto/company-setting.dto.ts +++ b/src/modules/field/settings/dto/company-setting.dto.ts @@ -25,6 +25,20 @@ export class UpdateCompanySettingDto { @Min(1) @Max(10_000) checkInRadiusMeters?: number; + + @ApiPropertyOptional({ example: 5, minimum: 5, maximum: 300 }) + @IsOptional() + @IsInt() + @Min(5) + @Max(300) + gpsIntervalSeconds?: number; + + @ApiPropertyOptional({ example: 200, minimum: 1, maximum: 10000 }) + @IsOptional() + @IsInt() + @Min(1) + @Max(10_000) + checkoutWarningRadiusMeters?: number; } export class CompanySettingDto { @@ -37,6 +51,12 @@ export class CompanySettingDto { @ApiProperty({ example: 100 }) checkInRadiusMeters!: number; + @ApiProperty({ example: 5 }) + gpsIntervalSeconds!: number; + + @ApiProperty({ example: 200 }) + checkoutWarningRadiusMeters!: number; + @ApiProperty() status!: string; diff --git a/src/modules/field/shared/field-purpose.ts b/src/modules/field/shared/field-purpose.ts index 31c74b7..b8f9949 100644 --- a/src/modules/field/shared/field-purpose.ts +++ b/src/modules/field/shared/field-purpose.ts @@ -30,6 +30,9 @@ export const ADMIN_LOGISTICS_PLAN_PRIVILEGE_KEY = export const MOBILE_SALES_PLAN_PRIVILEGE_KEY = 'MOBILE.SALES.PLAN'; export const MOBILE_LOGISTICS_PLAN_PRIVILEGE_KEY = 'MOBILE.LOGISTICS.PLAN'; export const SETTINGS_PRIVILEGE_KEY = 'ADMIN.SETTINGS.DATA.SETTING'; +export const ADMIN_SALES_TIMELINE_PRIVILEGE_KEY = + 'ADMIN.SALES.ACTIVITIES.TIMELINE'; +export const MOBILE_SALES_TIMELINE_PRIVILEGE_KEY = 'MOBILE.SALES.TIMELINE'; export const FIELD_ATTENDANCE_PRIVILEGE_KEY = 'MOBILE.SALES.PLAN.ATTENDANCE'; export const FIELD_VISIT_PRIVILEGE_KEY = 'MOBILE.SALES.VISIT'; diff --git a/src/modules/field/timeline/dto/timeline.dto.ts b/src/modules/field/timeline/dto/timeline.dto.ts new file mode 100644 index 0000000..173f21f --- /dev/null +++ b/src/modules/field/timeline/dto/timeline.dto.ts @@ -0,0 +1,149 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMaxSize, + ArrayNotEmpty, + IsArray, + IsIn, + IsInt, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + IsUUID, + Matches, + Max, + Min, + ValidateNested, +} from 'class-validator'; +import { + DefaultRelationDto, + PaginationMetaDto, +} from '../../../../common/http/response'; +import { TIMELINE_ACTIVITY_TYPES } from '../../../../database/timeline-activities-table'; + +export class TimelineConfigDto { + @ApiProperty({ example: 5 }) + gpsIntervalSeconds!: number; + + @ApiProperty({ example: 200 }) + checkoutWarningRadiusMeters!: number; +} + +export class TimelineFootprintPointDto { + @ApiProperty({ example: -6.2 }) + @IsNumber() + @Min(-90) + @Max(90) + latitude!: number; + + @ApiProperty({ example: 106.8 }) + @IsNumber() + @Min(-180) + @Max(180) + longitude!: number; + + @ApiProperty({ description: 'Unix ms when the point was recorded' }) + @IsInt() + @Min(1) + recordedAt!: number; +} + +export class IngestTimelineFootprintsDto { + @ApiProperty({ type: [TimelineFootprintPointDto] }) + @IsArray() + @ArrayNotEmpty() + @ArrayMaxSize(100) + @ValidateNested({ each: true }) + @Type(() => TimelineFootprintPointDto) + points!: TimelineFootprintPointDto[]; +} + +export class IngestTimelineFootprintsResultDto { + @ApiProperty() + inserted!: number; +} + +export class ListTimelineQueryDto { + @ApiPropertyOptional({ example: '2026-09-01', description: 'YYYY-MM-DD' }) + @IsOptional() + @IsString() + @IsNotEmpty() + @Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'date must be YYYY-MM-DD' }) + date?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID('4') + employeeId?: string; +} + +export class TimelineFootprintDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ type: DefaultRelationDto }) + employee!: DefaultRelationDto; + + @ApiProperty() + latitude!: number; + + @ApiProperty() + longitude!: number; + + @ApiProperty() + recordedAt!: number; +} + +export class TimelineActivityDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ type: DefaultRelationDto }) + employee!: DefaultRelationDto; + + @ApiProperty({ type: DefaultRelationDto, nullable: true }) + customer!: DefaultRelationDto | null; + + @ApiProperty({ format: 'uuid', nullable: true }) + visitId!: string | null; + + @ApiProperty({ enum: TIMELINE_ACTIVITY_TYPES }) + type!: string; + + @ApiProperty() + sourceType!: string; + + @ApiProperty({ format: 'uuid' }) + sourceId!: string; + + @ApiProperty() + latitude!: number; + + @ApiProperty() + longitude!: number; + + @ApiProperty() + recordedAt!: number; +} + +export class TimelineDayDto { + @ApiProperty() + date!: string; + + @ApiProperty({ type: [TimelineFootprintDto] }) + footprints!: TimelineFootprintDto[]; + + @ApiProperty({ type: [TimelineActivityDto] }) + activities!: TimelineActivityDto[]; +} + +export class TimelineMeDto { + @ApiProperty() + date!: string; + + @ApiProperty({ type: [TimelineActivityDto] }) + activities!: TimelineActivityDto[]; +} + +void PaginationMetaDto; diff --git a/src/modules/field/timeline/timeline-activities.repository.ts b/src/modules/field/timeline/timeline-activities.repository.ts new file mode 100644 index 0000000..3faf219 --- /dev/null +++ b/src/modules/field/timeline/timeline-activities.repository.ts @@ -0,0 +1,130 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { and, eq, gte, isNull, lte, sql } from 'drizzle-orm'; +import { DRIZZLE, type DrizzleDB } from '../../../database/database.module'; +import { customers } from '../../../database/customers-table'; +import { + timelineActivities, + type TimelineActivityRow, + type TimelineActivityType, +} from '../../../database/timeline-activities-table'; +import { visits } from '../../../database/visits-table'; +import { employees } from '../../../database/schema'; +import type { + ListTimelineFilters, + RecordTimelineActivityInput, + TimelineActivity, +} from './timeline.types'; + +@Injectable() +export class TimelineActivitiesRepository { + constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {} + + async insert(input: RecordTimelineActivityInput): Promise { + const recordedAt = input.recordedAt ?? Date.now(); + const inserted = await this.db + .insert(timelineActivities) + .values({ + employeeId: input.employeeId, + customerId: input.customerId ?? null, + visitId: input.visitId ?? null, + type: input.type, + sourceType: input.sourceType, + sourceId: input.sourceId, + latitude: input.latitude, + longitude: input.longitude, + recordedAt, + }) + .returning(); + const row = inserted[0]; + return { + id: row.id, + employeeId: row.employeeId, + customerId: row.customerId, + visitId: row.visitId, + type: row.type as TimelineActivityType, + sourceType: row.sourceType, + sourceId: row.sourceId, + latitude: row.latitude, + longitude: row.longitude, + recordedAt: row.recordedAt, + }; + } + + async findOpenVisitForEmployee( + employeeId: string, + ): Promise<{ visitId: string; customerId: string } | null> { + const rows = await this.db + .select({ + visitId: visits.id, + customerId: visits.customerId, + }) + .from(visits) + .where( + and( + eq(visits.employeeId, employeeId), + isNull(visits.checkOutAt), + sql`${visits.status} <> 'archived'`, + ), + ) + .limit(1); + const row = rows[0]; + return row ? { visitId: row.visitId, customerId: row.customerId } : null; + } + + async list(filters: ListTimelineFilters): Promise { + const conditions = [ + gte(timelineActivities.recordedAt, filters.dayStartMs), + lte(timelineActivities.recordedAt, filters.dayEndMs), + ]; + if (filters.employeeId) { + conditions.push(eq(timelineActivities.employeeId, filters.employeeId)); + } + + const rows = await this.db + .select({ + activity: timelineActivities, + employee: employees, + customer: customers, + }) + .from(timelineActivities) + .innerJoin(employees, eq(timelineActivities.employeeId, employees.id)) + .leftJoin(customers, eq(timelineActivities.customerId, customers.id)) + .where(and(...conditions)) + .orderBy(timelineActivities.recordedAt); + + return rows.map((row) => + this.toDomain(row.activity, row.employee, row.customer), + ); + } + + private toDomain( + row: TimelineActivityRow, + employee?: typeof employees.$inferSelect, + customer?: typeof customers.$inferSelect | null, + ): TimelineActivity { + return { + id: row.id, + employeeId: row.employeeId, + customerId: row.customerId, + visitId: row.visitId, + type: row.type as TimelineActivityType, + sourceType: row.sourceType, + sourceId: row.sourceId, + latitude: row.latitude, + longitude: row.longitude, + recordedAt: row.recordedAt, + ...(employee + ? { + employee: { + id: employee.id, + code: employee.code, + name: employee.name, + }, + } + : {}), + customer: customer + ? { id: customer.id, code: customer.code, name: customer.name } + : null, + }; + } +} diff --git a/src/modules/field/timeline/timeline-activities.service.ts b/src/modules/field/timeline/timeline-activities.service.ts new file mode 100644 index 0000000..813e149 --- /dev/null +++ b/src/modules/field/timeline/timeline-activities.service.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@nestjs/common'; +import type { TimelineActivityType } from '../../../database/timeline-activities-table'; +import { TimelineActivitiesRepository } from './timeline-activities.repository'; +import type { RecordTimelineActivityInput } from './timeline.types'; + +@Injectable() +export class TimelineActivitiesService { + constructor( + private readonly timelineActivitiesRepository: TimelineActivitiesRepository, + ) {} + + async record(input: RecordTimelineActivityInput): Promise { + const openVisit = + input.visitId === undefined && input.customerId === undefined + ? await this.timelineActivitiesRepository.findOpenVisitForEmployee( + input.employeeId, + ) + : null; + + await this.timelineActivitiesRepository.insert({ + ...input, + visitId: input.visitId ?? openVisit?.visitId ?? null, + customerId: + input.customerId ?? + openVisit?.customerId ?? + (input.type === 'customer_created' ? input.sourceId : null), + }); + } + + async recordIfLocated(input: { + readonly employeeId: string; + readonly type: TimelineActivityType; + readonly sourceType: string; + readonly sourceId: string; + readonly latitude?: number | null; + readonly longitude?: number | null; + readonly recordedAt?: number; + readonly customerId?: string | null; + readonly visitId?: string | null; + }): Promise { + if ( + input.latitude === undefined || + input.latitude === null || + input.longitude === undefined || + input.longitude === null + ) { + return; + } + await this.record({ + employeeId: input.employeeId, + type: input.type, + sourceType: input.sourceType, + sourceId: input.sourceId, + latitude: input.latitude, + longitude: input.longitude, + recordedAt: input.recordedAt, + customerId: input.customerId, + visitId: input.visitId, + }); + } +} diff --git a/src/modules/field/timeline/timeline-footprints.repository.ts b/src/modules/field/timeline/timeline-footprints.repository.ts new file mode 100644 index 0000000..81c7c16 --- /dev/null +++ b/src/modules/field/timeline/timeline-footprints.repository.ts @@ -0,0 +1,82 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { and, eq, gte, lte } from 'drizzle-orm'; +import { DRIZZLE, type DrizzleDB } from '../../../database/database.module'; +import { + timelineFootprints, + type TimelineFootprintRow, +} from '../../../database/timeline-footprints-table'; +import { employees } from '../../../database/schema'; +import type { + IngestFootprintPoint, + ListTimelineFilters, + TimelineFootprint, +} from './timeline.types'; + +@Injectable() +export class TimelineFootprintsRepository { + constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {} + + async insertMany( + employeeId: string, + points: readonly IngestFootprintPoint[], + ): Promise { + if (points.length === 0) { + return 0; + } + const rows = points.map((point) => ({ + employeeId, + latitude: point.latitude, + longitude: point.longitude, + recordedAt: point.recordedAt, + })); + const inserted = await this.db + .insert(timelineFootprints) + .values(rows) + .returning({ id: timelineFootprints.id }); + return inserted.length; + } + + async list(filters: ListTimelineFilters): Promise { + const conditions = [ + gte(timelineFootprints.recordedAt, filters.dayStartMs), + lte(timelineFootprints.recordedAt, filters.dayEndMs), + ]; + if (filters.employeeId) { + conditions.push(eq(timelineFootprints.employeeId, filters.employeeId)); + } + + const rows = await this.db + .select({ + footprint: timelineFootprints, + employee: employees, + }) + .from(timelineFootprints) + .innerJoin(employees, eq(timelineFootprints.employeeId, employees.id)) + .where(and(...conditions)) + .orderBy(timelineFootprints.recordedAt); + + return rows.map((row) => this.toDomain(row.footprint, row.employee)); + } + + private toDomain( + row: TimelineFootprintRow, + employee?: typeof employees.$inferSelect, + ): TimelineFootprint { + return { + id: row.id, + employeeId: row.employeeId, + latitude: row.latitude, + longitude: row.longitude, + recordedAt: row.recordedAt, + ...(employee + ? { + employee: { + id: employee.id, + code: employee.code, + name: employee.name, + }, + } + : {}), + }; + } +} diff --git a/src/modules/field/timeline/timeline-read.controller.ts b/src/modules/field/timeline/timeline-read.controller.ts new file mode 100644 index 0000000..782db1e --- /dev/null +++ b/src/modules/field/timeline/timeline-read.controller.ts @@ -0,0 +1,67 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiForbiddenResponse, + ApiOkResponse, + ApiOperation, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; +import { CurrentUser } from '../../../common/decorators/current-user.decorator'; +import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator'; +import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger'; +import { + ADMIN_SALES_TIMELINE_PRIVILEGE_KEY, + FIELD_ATTENDANCE_PRIVILEGE_KEY, + MOBILE_SALES_TIMELINE_PRIVILEGE_KEY, +} from '../shared/field-purpose'; +import { + ListTimelineQueryDto, + TimelineConfigDto, + TimelineDayDto, + TimelineMeDto, +} from './dto/timeline.dto'; +import { TimelineService } from './timeline.service'; + +@ApiTags('timeline') +@ApiBearerAuth(BEARER_AUTH_NAME) +@Controller('timeline') +export class TimelineReadController { + constructor(private readonly timelineService: TimelineService) {} + + @Get('config') + @RequirePrivilege( + [MOBILE_SALES_TIMELINE_PRIVILEGE_KEY, FIELD_ATTENDANCE_PRIVILEGE_KEY], + 'view', + ) + @ApiOperation({ summary: 'Get timeline tracking configuration' }) + @ApiOkResponse({ type: TimelineConfigDto }) + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + config(): Promise { + return this.timelineService.getConfig(); + } + + @Get('me') + @RequirePrivilege(MOBILE_SALES_TIMELINE_PRIVILEGE_KEY, 'view') + @ApiOperation({ summary: 'Get current user timeline activities for a day' }) + @ApiOkResponse({ type: TimelineMeDto }) + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + me( + @Query() query: ListTimelineQueryDto, + @CurrentUser('id') userId: string, + ): Promise { + return this.timelineService.getMyDay(query, userId); + } + + @Get() + @RequirePrivilege(ADMIN_SALES_TIMELINE_PRIVILEGE_KEY, 'view') + @ApiOperation({ summary: 'Get timeline footprints and activities for a day' }) + @ApiOkResponse({ type: TimelineDayDto }) + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + day(@Query() query: ListTimelineQueryDto): Promise { + return this.timelineService.getDay(query); + } +} diff --git a/src/modules/field/timeline/timeline-write.controller.ts b/src/modules/field/timeline/timeline-write.controller.ts new file mode 100644 index 0000000..767a72f --- /dev/null +++ b/src/modules/field/timeline/timeline-write.controller.ts @@ -0,0 +1,38 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiCreatedResponse, + ApiForbiddenResponse, + ApiOperation, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; +import { CurrentUser } from '../../../common/decorators/current-user.decorator'; +import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator'; +import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger'; +import { MOBILE_SALES_TIMELINE_PRIVILEGE_KEY } from '../shared/field-purpose'; +import { + IngestTimelineFootprintsDto, + IngestTimelineFootprintsResultDto, +} from './dto/timeline.dto'; +import { TimelineService } from './timeline.service'; + +@ApiTags('timeline') +@ApiBearerAuth(BEARER_AUTH_NAME) +@Controller('timeline') +export class TimelineWriteController { + constructor(private readonly timelineService: TimelineService) {} + + @Post('footprints') + @RequirePrivilege(MOBILE_SALES_TIMELINE_PRIVILEGE_KEY, 'create') + @ApiOperation({ summary: 'Ingest GPS footprint points' }) + @ApiCreatedResponse({ type: IngestTimelineFootprintsResultDto }) + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + ingestFootprints( + @Body() dto: IngestTimelineFootprintsDto, + @CurrentUser('id') userId: string, + ): Promise { + return this.timelineService.ingestFootprints(dto, userId); + } +} diff --git a/src/modules/field/timeline/timeline.module.ts b/src/modules/field/timeline/timeline.module.ts new file mode 100644 index 0000000..91cfd33 --- /dev/null +++ b/src/modules/field/timeline/timeline.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { EmployeesModule } from '../../configuration/employees/employees.module'; +import { CompanySettingsRepository } from '../settings/company-settings.repository'; +import { CompanySettingsService } from '../settings/company-settings.service'; +import { TimelineActivitiesRepository } from './timeline-activities.repository'; +import { TimelineActivitiesService } from './timeline-activities.service'; +import { TimelineFootprintsRepository } from './timeline-footprints.repository'; +import { TimelineReadController } from './timeline-read.controller'; +import { TimelineWriteController } from './timeline-write.controller'; +import { TimelineService } from './timeline.service'; + +@Module({ + imports: [EmployeesModule], + controllers: [TimelineReadController, TimelineWriteController], + providers: [ + CompanySettingsRepository, + CompanySettingsService, + TimelineFootprintsRepository, + TimelineActivitiesRepository, + TimelineActivitiesService, + TimelineService, + ], + exports: [TimelineActivitiesService, TimelineService], +}) +export class TimelineModule {} diff --git a/src/modules/field/timeline/timeline.service.spec.ts b/src/modules/field/timeline/timeline.service.spec.ts new file mode 100644 index 0000000..3767594 --- /dev/null +++ b/src/modules/field/timeline/timeline.service.spec.ts @@ -0,0 +1,121 @@ +import { BadRequestException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { DateTime } from '../../../common/value-objects/date-time/date-time'; +import { Status } from '../../../common/value-objects/status/status'; +import { EmployeesService } from '../../configuration/employees/employees.service'; +import type { CompanySetting } from '../settings/company-setting'; +import { CompanySettingsService } from '../settings/company-settings.service'; +import { TimelineActivitiesRepository } from './timeline-activities.repository'; +import { TimelineFootprintsRepository } from './timeline-footprints.repository'; +import { TimelineService } from './timeline.service'; + +describe('TimelineService', () => { + let service: TimelineService; + let footprintsRepository: jest.Mocked< + Pick + >; + let activitiesRepository: jest.Mocked>; + let employeesService: jest.Mocked>; + let companySettingsService: jest.Mocked< + Pick + >; + + const nowMs = DateTime.fromUnixMs(Date.now()).startOfDay().value + 3_600_000; + + beforeEach(async () => { + footprintsRepository = { + insertMany: jest.fn(), + list: jest.fn(), + }; + activitiesRepository = { + list: jest.fn(), + }; + employeesService = { + requireByUserId: jest.fn(), + }; + companySettingsService = { + requireTimelineConfig: jest.fn(), + }; + + const moduleRef: TestingModule = await Test.createTestingModule({ + providers: [ + TimelineService, + { provide: TimelineFootprintsRepository, useValue: footprintsRepository }, + { provide: TimelineActivitiesRepository, useValue: activitiesRepository }, + { provide: EmployeesService, useValue: employeesService }, + { provide: CompanySettingsService, useValue: companySettingsService }, + ], + }).compile(); + + service = moduleRef.get(TimelineService); + companySettingsService.requireTimelineConfig.mockResolvedValue({ + gpsIntervalSeconds: 5, + checkoutWarningRadiusMeters: 200, + }); + }); + + it('returns timeline config from company settings', async () => { + await expect(service.getConfig()).resolves.toEqual({ + gpsIntervalSeconds: 5, + checkoutWarningRadiusMeters: 200, + }); + }); + + it('ingests footprint points for the current employee', async () => { + employeesService.requireByUserId.mockResolvedValue({ + id: 'emp-1', + } as Awaited>); + footprintsRepository.insertMany.mockResolvedValue(1); + + const result = await service.ingestFootprints( + { + points: [{ latitude: -6.2, longitude: 106.8, recordedAt: nowMs }], + }, + 'user-1', + ); + + expect(result).toEqual({ inserted: 1 }); + expect(footprintsRepository.insertMany).toHaveBeenCalledWith('emp-1', [ + { latitude: -6.2, longitude: 106.8, recordedAt: nowMs }, + ]); + }); + + it('rejects stale footprint timestamps', async () => { + employeesService.requireByUserId.mockResolvedValue({ + id: 'emp-1', + } as Awaited>); + + await expect( + service.ingestFootprints( + { + points: [ + { + latitude: -6.2, + longitude: 106.8, + recordedAt: Date.now() - 25 * 60 * 60 * 1000, + }, + ], + }, + 'user-1', + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('downsamples footprints when querying all employees', async () => { + const footprints = Array.from({ length: 4000 }, (_, index) => ({ + id: `fp-${index}`, + employeeId: 'emp-1', + latitude: -6.2, + longitude: 106.8 + index * 0.0001, + recordedAt: nowMs + index * 1000, + employee: { id: 'emp-1', code: 'E1', name: 'Ada' }, + })); + footprintsRepository.list.mockResolvedValue(footprints); + activitiesRepository.list.mockResolvedValue([]); + + const result = await service.getDay({ date: '2026-09-01' }); + + expect(result.footprints.length).toBeLessThanOrEqual(2000); + expect(result.activities).toEqual([]); + }); +}); diff --git a/src/modules/field/timeline/timeline.service.ts b/src/modules/field/timeline/timeline.service.ts new file mode 100644 index 0000000..f919ec3 --- /dev/null +++ b/src/modules/field/timeline/timeline.service.ts @@ -0,0 +1,179 @@ +import { + BadRequestException, + Injectable, +} from '@nestjs/common'; +import { + DEFAULT_RELATION_FIELDS, + pickRelation, +} from '../../../common/http/response'; +import { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error'; +import { DateTime } from '../../../common/value-objects/date-time/date-time'; +import { EmployeesService } from '../../configuration/employees/employees.service'; +import { CompanySettingsService } from '../settings/company-settings.service'; +import type { + IngestTimelineFootprintsDto, + ListTimelineQueryDto, + TimelineActivityDto, + TimelineConfigDto, + TimelineDayDto, + TimelineFootprintDto, + TimelineMeDto, +} from './dto/timeline.dto'; +import { TimelineActivitiesRepository } from './timeline-activities.repository'; +import { TimelineFootprintsRepository } from './timeline-footprints.repository'; +import type { TimelineActivity, TimelineFootprint } from './timeline.types'; + +const MS_PER_DAY = 86_400_000; +const MAX_FOOTPRINTS_ALL_EMPLOYEES = 2000; +const MAX_FOOTPRINT_AGE_MS = 24 * 60 * 60 * 1000; + +@Injectable() +export class TimelineService { + constructor( + private readonly timelineFootprintsRepository: TimelineFootprintsRepository, + private readonly timelineActivitiesRepository: TimelineActivitiesRepository, + private readonly employeesService: EmployeesService, + private readonly companySettingsService: CompanySettingsService, + ) {} + + async getConfig(): Promise { + return this.companySettingsService.requireTimelineConfig(); + } + + async ingestFootprints( + dto: IngestTimelineFootprintsDto, + userId: string, + ): Promise<{ inserted: number }> { + const employee = await this.employeesService.requireByUserId(userId); + const now = Date.now(); + const points = dto.points.map((point) => { + this.assertCoordinates(point.latitude, point.longitude); + if (now - point.recordedAt > MAX_FOOTPRINT_AGE_MS) { + throw new BadRequestException('Footprint timestamp is too old'); + } + if (point.recordedAt > now + 60_000) { + throw new BadRequestException('Footprint timestamp is in the future'); + } + return point; + }); + + const inserted = await this.timelineFootprintsRepository.insertMany( + employee.id, + points, + ); + return { inserted }; + } + + async getDay(query: ListTimelineQueryDto): Promise { + const { dateLabel, filters } = this.resolveDayFilters(query); + const [footprints, activities] = await Promise.all([ + this.timelineFootprintsRepository.list(filters), + this.timelineActivitiesRepository.list(filters), + ]); + + return { + date: dateLabel, + footprints: this.downsampleFootprints(footprints, query.employeeId).map( + (item) => this.toFootprintDto(item), + ), + activities: activities.map((item) => this.toActivityDto(item)), + }; + } + + async getMyDay( + query: ListTimelineQueryDto, + userId: string, + ): Promise { + const employee = await this.employeesService.requireByUserId(userId); + const { dateLabel, filters } = this.resolveDayFilters({ + ...query, + employeeId: employee.id, + }); + const activities = await this.timelineActivitiesRepository.list(filters); + return { + date: dateLabel, + activities: activities.map((item) => this.toActivityDto(item)), + }; + } + + private resolveDayFilters(query: ListTimelineQueryDto): { + dateLabel: string; + filters: { + dayStartMs: number; + dayEndMs: number; + employeeId?: string; + }; + } { + const dateLabel = this.resolveDateLabel(query.date); + const dayStart = DateTime.create(dateLabel).startOfDay(); + return { + dateLabel, + filters: { + dayStartMs: dayStart.value, + dayEndMs: dayStart.value + MS_PER_DAY - 1, + employeeId: query.employeeId, + }, + }; + } + + private resolveDateLabel(raw?: string): string { + if (!raw) { + return DateTime.fromUnixMs(Date.now()).startOfDay().format().slice(0, 10); + } + try { + DateTime.create(raw); + return raw; + } catch (error) { + if (error instanceof InvalidDateTimeError) { + throw new BadRequestException('Invalid date'); + } + throw error; + } + } + + private downsampleFootprints( + footprints: readonly TimelineFootprint[], + employeeId?: string, + ): TimelineFootprint[] { + if (employeeId || footprints.length <= MAX_FOOTPRINTS_ALL_EMPLOYEES) { + return [...footprints]; + } + const stride = Math.ceil( + footprints.length / MAX_FOOTPRINTS_ALL_EMPLOYEES, + ); + return footprints.filter((_, index) => index % stride === 0); + } + + private assertCoordinates(latitude: number, longitude: number): void { + if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) { + throw new BadRequestException('Invalid coordinates'); + } + } + + private toFootprintDto(footprint: TimelineFootprint): TimelineFootprintDto { + return { + id: footprint.id, + employee: pickRelation(footprint.employee, DEFAULT_RELATION_FIELDS)!, + latitude: footprint.latitude, + longitude: footprint.longitude, + recordedAt: footprint.recordedAt, + }; + } + + private toActivityDto(activity: TimelineActivity): TimelineActivityDto { + return { + id: activity.id, + employee: pickRelation(activity.employee, DEFAULT_RELATION_FIELDS)!, + customer: activity.customer + ? pickRelation(activity.customer, DEFAULT_RELATION_FIELDS) + : null, + visitId: activity.visitId, + type: activity.type, + sourceType: activity.sourceType, + sourceId: activity.sourceId, + latitude: activity.latitude, + longitude: activity.longitude, + recordedAt: activity.recordedAt, + }; + } +} diff --git a/src/modules/field/timeline/timeline.types.ts b/src/modules/field/timeline/timeline.types.ts new file mode 100644 index 0000000..2871478 --- /dev/null +++ b/src/modules/field/timeline/timeline.types.ts @@ -0,0 +1,61 @@ +import type { TimelineActivityType } from '../../../database/timeline-activities-table'; + +export type TimelineFootprint = { + readonly id: string; + readonly employeeId: string; + readonly latitude: number; + readonly longitude: number; + readonly recordedAt: number; + readonly employee?: { + readonly id: string; + readonly code: string; + readonly name: string; + }; +}; + +export type TimelineActivity = { + readonly id: string; + readonly employeeId: string; + readonly customerId: string | null; + readonly visitId: string | null; + readonly type: TimelineActivityType; + readonly sourceType: string; + readonly sourceId: string; + readonly latitude: number; + readonly longitude: number; + readonly recordedAt: number; + readonly employee?: { + readonly id: string; + readonly code: string; + readonly name: string; + }; + readonly customer?: { + readonly id: string; + readonly code: string; + readonly name: string; + } | null; +}; + +export type RecordTimelineActivityInput = { + readonly employeeId: string; + readonly type: TimelineActivityType; + readonly sourceType: string; + readonly sourceId: string; + readonly latitude: number; + readonly longitude: number; + readonly recordedAt?: number; + readonly customerId?: string | null; + readonly visitId?: string | null; +}; + +export type IngestFootprintPoint = { + readonly latitude: number; + readonly longitude: number; + readonly recordedAt: number; +}; + +export type ListTimelineFilters = { + readonly dayStartMs: number; + readonly dayEndMs: number; + readonly employeeId?: string; +}; diff --git a/src/modules/field/visits/visits.service.ts b/src/modules/field/visits/visits.service.ts index b9759c6..d980474 100644 --- a/src/modules/field/visits/visits.service.ts +++ b/src/modules/field/visits/visits.service.ts @@ -23,6 +23,7 @@ import { type CheckInPayload, } from '../shared/check-in-verification'; import { FIELD_VISIT_PRIVILEGE_KEY } from '../shared/field-purpose'; +import { TimelineActivitiesService } from '../timeline/timeline-activities.service'; import type { Visit } from './visit'; import type { ListVisitsQueryDto, @@ -40,6 +41,7 @@ export class VisitsService { private readonly customersService: CustomersService, private readonly employeesService: EmployeesService, private readonly companySettingsService: CompanySettingsService, + private readonly timelineActivitiesService: TimelineActivitiesService, ) {} async list(query: ListVisitsQueryDto): Promise> { @@ -131,6 +133,17 @@ export class VisitsService { checkInDistanceMeters: verified.distanceMeters, userId, }); + await this.timelineActivitiesService.recordIfLocated({ + employeeId: employee.id, + type: 'customer_check_in', + sourceType: 'visit', + sourceId: created.id, + latitude: verified.latitude, + longitude: verified.longitude, + recordedAt: now.value, + customerId: dto.customerId, + visitId: created.id, + }); return this.toItem(created); } @@ -175,6 +188,17 @@ export class VisitsService { checkOutDistanceMeters: verified.distanceMeters, userId, }); + await this.timelineActivitiesService.recordIfLocated({ + employeeId: employee.id, + type: 'customer_check_out', + sourceType: 'visit', + sourceId: updated.id, + latitude: verified.latitude, + longitude: verified.longitude, + recordedAt: updated.checkOutAt?.value, + customerId: visit.customerId, + visitId: updated.id, + }); return this.toItem(updated); } diff --git a/src/modules/sales/sales-orders/sales-orders.module.ts b/src/modules/sales/sales-orders/sales-orders.module.ts index a71ec64..b05f6f9 100644 --- a/src/modules/sales/sales-orders/sales-orders.module.ts +++ b/src/modules/sales/sales-orders/sales-orders.module.ts @@ -4,6 +4,7 @@ import { CustomersModule } from '../../configuration/customers/customers.module' import { DivisionsModule } from '../../configuration/divisions/divisions.module'; import { EmployeesModule } from '../../configuration/employees/employees.module'; import { ProductsModule } from '../../configuration/products/products.module'; +import { TimelineModule } from '../../field/timeline/timeline.module'; import { SalesRequestsModule } from '../sales-requests/sales-requests.module'; import { DocumentCodeService } from '../shared/document-code.service'; import { SalesDocumentFlowModule } from '../shared/sales-document-flow.module'; @@ -20,6 +21,7 @@ import { SalesOrdersService } from './sales-orders.service'; CustomersModule, ProductsModule, SalesRequestsModule, + TimelineModule, forwardRef(() => SalesDocumentFlowModule), ], controllers: [SalesOrdersReadController, SalesOrdersWriteController], diff --git a/src/modules/sales/sales-orders/sales-orders.service.ts b/src/modules/sales/sales-orders/sales-orders.service.ts index 6aefd3e..5a9921b 100644 --- a/src/modules/sales/sales-orders/sales-orders.service.ts +++ b/src/modules/sales/sales-orders/sales-orders.service.ts @@ -23,6 +23,7 @@ import { CustomersService } from '../../configuration/customers/customers.servic import { DivisionsService } from '../../configuration/divisions/divisions.service'; import { EmployeesService } from '../../configuration/employees/employees.service'; import { ProductsService } from '../../configuration/products/products.service'; +import { TimelineActivitiesService } from '../../field/timeline/timeline-activities.service'; import { SalesRequestsService } from '../sales-requests/sales-requests.service'; import { isValidDocumentAddress, @@ -95,6 +96,7 @@ export class SalesOrdersService { private readonly salesRequestsService: SalesRequestsService, @Inject(forwardRef(() => SalesDocumentFlowService)) private readonly salesDocumentFlowService: SalesDocumentFlowService, + private readonly timelineActivitiesService: TimelineActivitiesService, ) {} async list( @@ -152,6 +154,14 @@ export class SalesOrdersService { const created = await this.salesOrdersRepository.create( await this.toCreateInput(merged), ); + await this.timelineActivitiesService.recordIfLocated({ + employeeId: created.salesPersonId, + type: 'sales_order_created', + sourceType: 'sales-order', + sourceId: created.id, + latitude: created.latitude, + longitude: created.longitude, + }); return this.toDetail(created); } diff --git a/src/modules/sales/sales-payments/dto/sales-payment.dto.ts b/src/modules/sales/sales-payments/dto/sales-payment.dto.ts index eb650eb..1df0088 100644 --- a/src/modules/sales/sales-payments/dto/sales-payment.dto.ts +++ b/src/modules/sales/sales-payments/dto/sales-payment.dto.ts @@ -5,11 +5,14 @@ import { IsArray, IsIn, IsNotEmpty, + IsNumber, IsOptional, IsString, IsUUID, Matches, + Max, MaxLength, + Min, ValidateNested, } from 'class-validator'; @@ -89,6 +92,20 @@ export class CreateSalesPaymentDto { @IsOptional() @IsIn([...SALES_PAYMENT_STATUSES]) status?: string; + + @ApiPropertyOptional({ example: -6.2 }) + @IsOptional() + @IsNumber() + @Min(-90) + @Max(90) + latitude?: number; + + @ApiPropertyOptional({ example: 106.8 }) + @IsOptional() + @IsNumber() + @Min(-180) + @Max(180) + longitude?: number; } export class UpdateSalesPaymentDto { diff --git a/src/modules/sales/sales-payments/sales-payments-write.controller.ts b/src/modules/sales/sales-payments/sales-payments-write.controller.ts index f2d6c1f..49477a9 100644 --- a/src/modules/sales/sales-payments/sales-payments-write.controller.ts +++ b/src/modules/sales/sales-payments/sales-payments-write.controller.ts @@ -134,6 +134,8 @@ export class SalesPaymentsWriteController { invoices: dto.invoices, images: dto.images, status: dto.status, + latitude: dto.latitude, + longitude: dto.longitude, userId, }); } diff --git a/src/modules/sales/sales-payments/sales-payments.module.ts b/src/modules/sales/sales-payments/sales-payments.module.ts index 4252282..0255757 100644 --- a/src/modules/sales/sales-payments/sales-payments.module.ts +++ b/src/modules/sales/sales-payments/sales-payments.module.ts @@ -1,4 +1,6 @@ import { Module } from '@nestjs/common'; +import { EmployeesModule } from '../../configuration/employees/employees.module'; +import { TimelineModule } from '../../field/timeline/timeline.module'; import { SalesInvoicesModule } from '../sales-invoices/sales-invoices.module'; import { DocumentCodeService } from '../shared/document-code.service'; import { SalesPaymentsReadController } from './sales-payments-read.controller'; @@ -7,7 +9,7 @@ import { SalesPaymentsRepository } from './sales-payments.repository'; import { SalesPaymentsService } from './sales-payments.service'; @Module({ - imports: [SalesInvoicesModule], + imports: [SalesInvoicesModule, EmployeesModule, TimelineModule], controllers: [SalesPaymentsReadController, SalesPaymentsWriteController], providers: [ DocumentCodeService, diff --git a/src/modules/sales/sales-payments/sales-payments.service.ts b/src/modules/sales/sales-payments/sales-payments.service.ts index 5f9bc1c..16397e9 100644 --- a/src/modules/sales/sales-payments/sales-payments.service.ts +++ b/src/modules/sales/sales-payments/sales-payments.service.ts @@ -14,6 +14,8 @@ import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { InvalidDecimalError } from '../../../common/value-objects/decimal/invalid-decimal.error'; import { Status } from '../../../common/value-objects/status/status'; +import { EmployeesService } from '../../configuration/employees/employees.service'; +import { TimelineActivitiesService } from '../../field/timeline/timeline-activities.service'; import { SalesInvoicesService } from '../sales-invoices/sales-invoices.service'; import { isValidDocumentCode, @@ -65,6 +67,8 @@ export class SalesPaymentsService { constructor( private readonly salesPaymentsRepository: SalesPaymentsRepository, private readonly salesInvoicesService: SalesInvoicesService, + private readonly employeesService: EmployeesService, + private readonly timelineActivitiesService: TimelineActivitiesService, ) {} async list( @@ -109,11 +113,22 @@ export class SalesPaymentsService { invoices: PaymentAllocationBody[]; images?: SalesImageBody[]; status?: string; + latitude?: number | null; + longitude?: number | null; userId: string; }): Promise> { const created = await this.salesPaymentsRepository.create( await this.toCreateInput(input), ); + const employee = await this.employeesService.requireByUserId(input.userId); + await this.timelineActivitiesService.recordIfLocated({ + employeeId: employee.id, + type: 'sales_payment_created', + sourceType: 'sales-payment', + sourceId: created.id, + latitude: input.latitude, + longitude: input.longitude, + }); return this.toDetail(created); } diff --git a/src/modules/sales/sales-requests/sales-requests.module.ts b/src/modules/sales/sales-requests/sales-requests.module.ts index e03b1e2..6cdc926 100644 --- a/src/modules/sales/sales-requests/sales-requests.module.ts +++ b/src/modules/sales/sales-requests/sales-requests.module.ts @@ -4,6 +4,7 @@ import { CustomersModule } from '../../configuration/customers/customers.module' import { DivisionsModule } from '../../configuration/divisions/divisions.module'; import { EmployeesModule } from '../../configuration/employees/employees.module'; import { ProductsModule } from '../../configuration/products/products.module'; +import { TimelineModule } from '../../field/timeline/timeline.module'; import { DocumentCodeService } from '../shared/document-code.service'; import { SalesRequestsReadController } from './sales-requests-read.controller'; import { SalesRequestsWriteController } from './sales-requests-write.controller'; @@ -17,6 +18,7 @@ import { SalesRequestsService } from './sales-requests.service'; DivisionsModule, CustomersModule, ProductsModule, + TimelineModule, ], controllers: [SalesRequestsReadController, SalesRequestsWriteController], providers: [ diff --git a/src/modules/sales/sales-requests/sales-requests.service.ts b/src/modules/sales/sales-requests/sales-requests.service.ts index d9b77a4..6f11231 100644 --- a/src/modules/sales/sales-requests/sales-requests.service.ts +++ b/src/modules/sales/sales-requests/sales-requests.service.ts @@ -20,6 +20,7 @@ import { CustomersService } from '../../configuration/customers/customers.servic import { DivisionsService } from '../../configuration/divisions/divisions.service'; import { EmployeesService } from '../../configuration/employees/employees.service'; import { ProductsService } from '../../configuration/products/products.service'; +import { TimelineActivitiesService } from '../../field/timeline/timeline-activities.service'; import { isValidDocumentAddress, isValidDocumentCode, @@ -84,6 +85,7 @@ export class SalesRequestsService { private readonly divisionsService: DivisionsService, private readonly customersService: CustomersService, private readonly productsService: ProductsService, + private readonly timelineActivitiesService: TimelineActivitiesService, ) {} async list( @@ -141,6 +143,14 @@ export class SalesRequestsService { const created = await this.salesRequestsRepository.create( await this.toCreateInput(input), ); + await this.timelineActivitiesService.recordIfLocated({ + employeeId: created.salesPersonId, + type: 'sales_request_created', + sourceType: 'sales-request', + sourceId: created.id, + latitude: created.latitude, + longitude: created.longitude, + }); return this.toDetail(created); } diff --git a/test/company-settings.e2e-spec.ts b/test/company-settings.e2e-spec.ts index 01b0e9b..a4c2ed2 100644 --- a/test/company-settings.e2e-spec.ts +++ b/test/company-settings.e2e-spec.ts @@ -107,4 +107,23 @@ describe('Company settings (e2e)', () => { (updated.body as { cycleStartDate: number }).cycleStartDate, ); }); + + it('upserts timeline tracking settings', async () => { + const updated = await request(app.getHttpServer()) + .patch('/settings') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ + gpsIntervalSeconds: 15, + checkoutWarningRadiusMeters: 300, + }) + .expect(200); + + expect((updated.body as { gpsIntervalSeconds: number }).gpsIntervalSeconds).toBe( + 15, + ); + expect( + (updated.body as { checkoutWarningRadiusMeters: number }) + .checkoutWarningRadiusMeters, + ).toBe(300); + }); }); diff --git a/test/timeline.e2e-spec.ts b/test/timeline.e2e-spec.ts new file mode 100644 index 0000000..9ffb5bb --- /dev/null +++ b/test/timeline.e2e-spec.ts @@ -0,0 +1,183 @@ +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { eq } from 'drizzle-orm'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { AppModule } from '../src/app.module'; +import { configureApp } from '../src/common/configure-app'; +import { DateTime } from '../src/common/value-objects/date-time/date-time'; +import { DRIZZLE, type DrizzleDB } from '../src/database/database.module'; +import { + privilegeDetails, + privilegeKeys, + privileges, + users, +} from '../src/database/schema'; +import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action'; +import { registerAndActivate } from './helpers/activate-user'; + +describe('Timeline (e2e)', () => { + let app: INestApplication; + let db: DrizzleDB; + + const password = 'password123'; + const adminUsername = `tl_admin_${Date.now()}`; + const otherUsername = `tl_other_${Date.now()}`; + + let adminAccessToken: string; + let adminUserId: string; + let otherAccessToken: string; + let employeeId: string; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + configureApp(app, { + NODE_ENV: 'test', + SWAGGER_ENABLED: 'false', + }); + await app.init(); + db = app.get(DRIZZLE); + + const admin = await registerAndActivate(app, db, adminUsername, password); + adminAccessToken = admin.accessToken; + adminUserId = admin.userId; + + const other = await registerAndActivate(app, db, otherUsername, password); + otherAccessToken = other.accessToken; + + const now = Date.now(); + const [priv] = await db + .insert(privileges) + .values({ + name: 'Timeline Admin', + code: `TL_ADMIN_${now}`, + status: 'active', + createdAt: now, + updatedAt: now, + createdBy: adminUserId, + updatedBy: adminUserId, + }) + .returning(); + + const keys = await db.select().from(privilegeKeys); + await db.insert(privilegeDetails).values( + keys.flatMap((key) => + PRIVILEGE_ACTIONS.map((action) => ({ + privilegeId: priv.id, + privilegeKeyId: key.id, + action, + value: true, + })), + ), + ); + await db + .update(users) + .set({ privilegeId: priv.id, updatedAt: Date.now() }) + .where(eq(users.id, adminUserId)); + + const login = await request(app.getHttpServer()) + .post('/auth/login') + .send({ username: adminUsername, password }) + .expect(200); + adminAccessToken = (login.body as { accessToken: string }).accessToken; + + await request(app.getHttpServer()) + .patch('/settings') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ + cycleStartDate: '2026-01-05', + gpsIntervalSeconds: 10, + checkoutWarningRadiusMeters: 250, + }) + .expect(200); + + const suffix = Date.now().toString().slice(-6); + const employee = await request(app.getHttpServer()) + .post('/employees') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ + code: `TL_E_${suffix}`, + name: 'Timeline Tester', + phone: '+6281234567891', + position: 'sales', + status: 'active', + userId: adminUserId, + }) + .expect(201); + employeeId = (employee.body as { id: string }).id; + }); + + afterAll(async () => { + await app.close(); + }); + + it('returns timeline config for mobile attendance privilege', async () => { + const response = await request(app.getHttpServer()) + .get('/timeline/config') + .set('Authorization', `Bearer ${adminAccessToken}`) + .expect(200); + + expect(response.body).toEqual({ + gpsIntervalSeconds: 10, + checkoutWarningRadiusMeters: 250, + }); + }); + + it('forbids timeline config without permission', async () => { + await request(app.getHttpServer()) + .get('/timeline/config') + .set('Authorization', `Bearer ${otherAccessToken}`) + .expect(403); + }); + + it('ingests footprints and returns them on admin timeline query', async () => { + const recordedAt = DateTime.fromUnixMs(Date.now()).startOfDay().value + 3_600_000; + + const ingest = await request(app.getHttpServer()) + .post('/timeline/footprints') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ + points: [ + { latitude: -6.2, longitude: 106.8, recordedAt }, + { latitude: -6.201, longitude: 106.801, recordedAt: recordedAt + 5000 }, + ], + }) + .expect(201); + + expect((ingest.body as { inserted: number }).inserted).toBe(2); + + const today = DateTime.fromUnixMs(Date.now()).startOfDay().format().slice(0, 10); + const day = await request(app.getHttpServer()) + .get('/timeline') + .query({ date: today, employeeId }) + .set('Authorization', `Bearer ${adminAccessToken}`) + .expect(200); + + const body = day.body as { + date: string; + footprints: Array<{ latitude: number; longitude: number }>; + activities: unknown[]; + }; + expect(body.date).toBe(today); + expect(body.footprints.length).toBeGreaterThanOrEqual(2); + expect(body.activities).toEqual([]); + }); + + it('returns activities-only timeline for the current user', async () => { + const today = DateTime.fromUnixMs(Date.now()).startOfDay().format().slice(0, 10); + const me = await request(app.getHttpServer()) + .get('/timeline/me') + .query({ date: today }) + .set('Authorization', `Bearer ${adminAccessToken}`) + .expect(200); + + const body = me.body as { date: string; activities: unknown[] }; + expect(body.date).toBe(today); + expect(Array.isArray(body.activities)).toBe(true); + expect(body).not.toHaveProperty('footprints'); + }); +});