Add timeline tracking features with database schema updates

- Introduced new tables `timeline_footprints` and `timeline_activities` to manage employee location data and activity records.
- Updated `company_settings` to include `gps_interval_seconds` and `checkout_warning_radius_meters` for enhanced tracking configuration.
- Implemented foreign key constraints to ensure data integrity between new tables and existing `employees`, `customers`, and `visits` tables.
- Created services and controllers for handling timeline activities and footprints, including ingestion and retrieval of data.
- Enhanced DTOs and validation logic to support new fields and ensure correct data formats in API requests.
- Added unit and integration tests to validate the new functionalities and ensure proper handling of timeline records.
- Created migration scripts to apply the necessary database schema changes for the new features.
This commit is contained in:
shancheas
2026-09-01 20:36:47 +07:00
parent 9428a983f5
commit 4b48dbdf3c
37 changed files with 1477 additions and 2 deletions
+42
View File
@@ -0,0 +1,42 @@
ALTER TABLE "company_settings" ADD COLUMN "gps_interval_seconds" integer DEFAULT 5 NOT NULL;
--> statement-breakpoint
ALTER TABLE "company_settings" ADD COLUMN "checkout_warning_radius_meters" integer DEFAULT 200 NOT NULL;
--> statement-breakpoint
CREATE TABLE "timeline_footprints" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"employee_id" uuid NOT NULL,
"latitude" double precision NOT NULL,
"longitude" double precision NOT NULL,
"recorded_at" bigint NOT NULL
);
--> statement-breakpoint
CREATE TABLE "timeline_activities" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"employee_id" uuid NOT NULL,
"customer_id" uuid,
"visit_id" uuid,
"type" text NOT NULL,
"source_type" text NOT NULL,
"source_id" uuid NOT NULL,
"latitude" double precision NOT NULL,
"longitude" double precision NOT NULL,
"recorded_at" bigint NOT NULL
);
--> statement-breakpoint
ALTER TABLE "timeline_footprints" ADD CONSTRAINT "timeline_footprints_employee_id_employees_id_fk" FOREIGN KEY ("employee_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "timeline_activities" ADD CONSTRAINT "timeline_activities_employee_id_employees_id_fk" FOREIGN KEY ("employee_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "timeline_activities" ADD CONSTRAINT "timeline_activities_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE set null ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "timeline_activities" ADD CONSTRAINT "timeline_activities_visit_id_visits_id_fk" FOREIGN KEY ("visit_id") REFERENCES "public"."visits"("id") ON DELETE set null ON UPDATE no action;
--> statement-breakpoint
CREATE INDEX "timeline_footprints_employee_recorded_idx" ON "timeline_footprints" USING btree ("employee_id","recorded_at");
--> statement-breakpoint
CREATE INDEX "timeline_activities_employee_recorded_idx" ON "timeline_activities" USING btree ("employee_id","recorded_at");
--> statement-breakpoint
CREATE INDEX "timeline_activities_visit_id_idx" ON "timeline_activities" USING btree ("visit_id");
--> statement-breakpoint
INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES
('ADMIN.SALES.ACTIVITIES.TIMELINE', 'Sales timeline', 119),
('MOBILE.SALES.TIMELINE', 'Sales timeline (mobile)', 210);
+7
View File
@@ -120,6 +120,13 @@
"when": 1787564000000, "when": 1787564000000,
"tag": "0016_privilege_key_hierarchy", "tag": "0016_privilege_key_hierarchy",
"breakpoints": true "breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1787565000000,
"tag": "0017_timeline",
"breakpoints": true
} }
] ]
} }
+4
View File
@@ -9,6 +9,10 @@ export const companySettings = pgTable('company_settings', {
id: uuid('id').defaultRandom().notNull().primaryKey(), id: uuid('id').defaultRandom().notNull().primaryKey(),
cycleStartDate: bigint('cycle_start_date', { mode: 'number' }).notNull(), cycleStartDate: bigint('cycle_start_date', { mode: 'number' }).notNull(),
checkInRadiusMeters: integer('check_in_radius_meters').notNull().default(100), 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), ...primaryEntityColumns(users),
}); });
+12
View File
@@ -253,6 +253,18 @@ export {
type NewAttendanceRow, type NewAttendanceRow,
} from './attendances-table'; } from './attendances-table';
export { visits, type VisitRow, type NewVisitRow } from './visits-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 { export {
reportBookmarks, reportBookmarks,
type NewReportBookmarkRow, type NewReportBookmarkRow,
+56
View File
@@ -0,0 +1,56 @@
import {
bigint,
doublePrecision,
index,
pgTable,
text,
uuid,
} from 'drizzle-orm/pg-core';
import { customers } from './customers-table';
import { employees } from './employees-table';
import { visits } from './visits-table';
export const TIMELINE_ACTIVITY_TYPES = [
'branch_check_in',
'branch_check_out',
'customer_check_in',
'customer_check_out',
'sales_order_created',
'sales_request_created',
'sales_payment_created',
'customer_created',
] as const;
export type TimelineActivityType = (typeof TIMELINE_ACTIVITY_TYPES)[number];
export const timelineActivities = pgTable(
'timeline_activities',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
employeeId: uuid('employee_id')
.notNull()
.references(() => employees.id, { onDelete: 'restrict' }),
customerId: uuid('customer_id').references(() => customers.id, {
onDelete: 'set null',
}),
visitId: uuid('visit_id').references(() => visits.id, {
onDelete: 'set null',
}),
type: text('type').notNull(),
sourceType: text('source_type').notNull(),
sourceId: uuid('source_id').notNull(),
latitude: doublePrecision('latitude').notNull(),
longitude: doublePrecision('longitude').notNull(),
recordedAt: bigint('recorded_at', { mode: 'number' }).notNull(),
},
(t) => [
index('timeline_activities_employee_recorded_idx').on(
t.employeeId,
t.recordedAt,
),
index('timeline_activities_visit_id_idx').on(t.visitId),
],
);
export type TimelineActivityRow = typeof timelineActivities.$inferSelect;
export type NewTimelineActivityRow = typeof timelineActivities.$inferInsert;
+24
View File
@@ -0,0 +1,24 @@
import { bigint, doublePrecision, index, pgTable, uuid } from 'drizzle-orm/pg-core';
import { employees } from './employees-table';
export const timelineFootprints = pgTable(
'timeline_footprints',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
employeeId: uuid('employee_id')
.notNull()
.references(() => employees.id, { onDelete: 'restrict' }),
latitude: doublePrecision('latitude').notNull(),
longitude: doublePrecision('longitude').notNull(),
recordedAt: bigint('recorded_at', { mode: 'number' }).notNull(),
},
(t) => [
index('timeline_footprints_employee_recorded_idx').on(
t.employeeId,
t.recordedAt,
),
],
);
export type TimelineFootprintRow = typeof timelineFootprints.$inferSelect;
export type NewTimelineFootprintRow = typeof timelineFootprints.$inferInsert;
@@ -1,10 +1,13 @@
import { Module } from '@nestjs/common'; 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 { CustomersReadController } from './customers-read.controller';
import { CustomersWriteController } from './customers-write.controller'; import { CustomersWriteController } from './customers-write.controller';
import { CustomersRepository } from './customers.repository'; import { CustomersRepository } from './customers.repository';
import { CustomersService } from './customers.service'; import { CustomersService } from './customers.service';
@Module({ @Module({
imports: [EmployeesModule, TimelineModule],
controllers: [CustomersReadController, CustomersWriteController], controllers: [CustomersReadController, CustomersWriteController],
providers: [CustomersRepository, CustomersService], providers: [CustomersRepository, CustomersService],
exports: [CustomersService], exports: [CustomersService],
@@ -29,6 +29,8 @@ import {
parseCsvRecord, parseCsvRecord,
} from './customer-fields'; } from './customer-fields';
import { CustomersRepository } from './customers.repository'; import { CustomersRepository } from './customers.repository';
import { EmployeesService } from '../employees/employees.service';
import { TimelineActivitiesService } from '../../field/timeline/timeline-activities.service';
export type ListCustomersQuery = { export type ListCustomersQuery = {
readonly code?: string; readonly code?: string;
@@ -73,7 +75,11 @@ const CSV_REQUIRED_HEADERS = ['code', 'name', 'phone', 'address'] as const;
@Injectable() @Injectable()
export class CustomersService { export class CustomersService {
constructor(private readonly customersRepository: CustomersRepository) {} constructor(
private readonly customersRepository: CustomersRepository,
private readonly employeesService: EmployeesService,
private readonly timelineActivitiesService: TimelineActivitiesService,
) {}
async list( async list(
query: ListCustomersQuery, query: ListCustomersQuery,
@@ -133,6 +139,16 @@ export class CustomersService {
const created = await this.customersRepository.create( const created = await this.customersRepository.create(
this.toCreateInput(input), 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); return this.toDetail(created);
} }
@@ -23,6 +23,7 @@ import {
type CheckInPayload, type CheckInPayload,
} from '../shared/check-in-verification'; } from '../shared/check-in-verification';
import { FIELD_ATTENDANCE_PRIVILEGE_KEY } from '../shared/field-purpose'; import { FIELD_ATTENDANCE_PRIVILEGE_KEY } from '../shared/field-purpose';
import { TimelineActivitiesService } from '../timeline/timeline-activities.service';
import type { Attendance } from './attendance'; import type { Attendance } from './attendance';
import type { import type {
AttendanceCheckInDto, AttendanceCheckInDto,
@@ -39,6 +40,7 @@ export class AttendancesService {
private readonly branchesService: BranchesService, private readonly branchesService: BranchesService,
private readonly employeesService: EmployeesService, private readonly employeesService: EmployeesService,
private readonly companySettingsService: CompanySettingsService, private readonly companySettingsService: CompanySettingsService,
private readonly timelineActivitiesService: TimelineActivitiesService,
) {} ) {}
async list( async list(
@@ -119,6 +121,15 @@ export class AttendancesService {
checkInDistanceMeters: verified.distanceMeters, checkInDistanceMeters: verified.distanceMeters,
userId, 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); return this.toItem(created);
} }
@@ -164,6 +175,15 @@ export class AttendancesService {
checkOutDistanceMeters: verified.distanceMeters, checkOutDistanceMeters: verified.distanceMeters,
userId, 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); return this.toItem(updated);
} }
+3
View File
@@ -25,6 +25,7 @@ import { VisitsWriteController } from './visits/visits-write.controller';
import { VisitsRepository } from './visits/visits.repository'; import { VisitsRepository } from './visits/visits.repository';
import { VisitsService } from './visits/visits.service'; import { VisitsService } from './visits/visits.service';
import { FieldPrivilegeGuard } from './shared/field-privilege.guard'; import { FieldPrivilegeGuard } from './shared/field-privilege.guard';
import { TimelineModule } from './timeline/timeline.module';
@Module({ @Module({
imports: [ imports: [
@@ -34,6 +35,7 @@ import { FieldPrivilegeGuard } from './shared/field-privilege.guard';
CustomersModule, CustomersModule,
SalesInvoicesModule, SalesInvoicesModule,
PackingSlipsModule, PackingSlipsModule,
TimelineModule,
], ],
controllers: [ controllers: [
CompanySettingsController, CompanySettingsController,
@@ -65,6 +67,7 @@ import { FieldPrivilegeGuard } from './shared/field-privilege.guard';
CyclesService, CyclesService,
PlansService, PlansService,
VisitsService, VisitsService,
TimelineModule,
], ],
}) })
export class FieldModule {} export class FieldModule {}
@@ -5,6 +5,8 @@ export type CompanySetting = {
readonly id: string; readonly id: string;
readonly cycleStartDate: DateTime; readonly cycleStartDate: DateTime;
readonly checkInRadiusMeters: number; readonly checkInRadiusMeters: number;
readonly gpsIntervalSeconds: number;
readonly checkoutWarningRadiusMeters: number;
readonly status: Status; readonly status: Status;
readonly createdAt: DateTime; readonly createdAt: DateTime;
readonly updatedAt: DateTime; readonly updatedAt: DateTime;
@@ -15,5 +17,7 @@ export type CompanySetting = {
export type UpsertCompanySettingInput = { export type UpsertCompanySettingInput = {
readonly cycleStartDate: DateTime; readonly cycleStartDate: DateTime;
readonly checkInRadiusMeters?: number; readonly checkInRadiusMeters?: number;
readonly gpsIntervalSeconds?: number;
readonly checkoutWarningRadiusMeters?: number;
readonly userId: string; readonly userId: string;
}; };
@@ -34,6 +34,8 @@ export class CompanySettingsRepository {
.values({ .values({
cycleStartDate: input.cycleStartDate.value, cycleStartDate: input.cycleStartDate.value,
checkInRadiusMeters: input.checkInRadiusMeters ?? 100, checkInRadiusMeters: input.checkInRadiusMeters ?? 100,
gpsIntervalSeconds: input.gpsIntervalSeconds ?? 5,
checkoutWarningRadiusMeters: input.checkoutWarningRadiusMeters ?? 200,
status: Status.create(Status.DEFAULT).value, status: Status.create(Status.DEFAULT).value,
createdAt: now.value, createdAt: now.value,
updatedAt: now.value, updatedAt: now.value,
@@ -50,6 +52,12 @@ export class CompanySettingsRepository {
...(input.checkInRadiusMeters !== undefined ...(input.checkInRadiusMeters !== undefined
? { checkInRadiusMeters: input.checkInRadiusMeters } ? { checkInRadiusMeters: input.checkInRadiusMeters }
: {}), : {}),
...(input.gpsIntervalSeconds !== undefined
? { gpsIntervalSeconds: input.gpsIntervalSeconds }
: {}),
...(input.checkoutWarningRadiusMeters !== undefined
? { checkoutWarningRadiusMeters: input.checkoutWarningRadiusMeters }
: {}),
updatedAt: now.value, updatedAt: now.value,
updatedBy: input.userId, updatedBy: input.userId,
}) })
@@ -63,6 +71,8 @@ export class CompanySettingsRepository {
id: row.id, id: row.id,
cycleStartDate: DateTime.fromUnixMs(row.cycleStartDate), cycleStartDate: DateTime.fromUnixMs(row.cycleStartDate),
checkInRadiusMeters: row.checkInRadiusMeters, checkInRadiusMeters: row.checkInRadiusMeters,
gpsIntervalSeconds: row.gpsIntervalSeconds,
checkoutWarningRadiusMeters: row.checkoutWarningRadiusMeters,
status: Status.create(row.status), status: Status.create(row.status),
createdAt: DateTime.fromUnixMs(row.createdAt), createdAt: DateTime.fromUnixMs(row.createdAt),
updatedAt: DateTime.fromUnixMs(row.updatedAt), updatedAt: DateTime.fromUnixMs(row.updatedAt),
@@ -17,6 +17,8 @@ describe('CompanySettingsService', () => {
id: 'set-1', id: 'set-1',
cycleStartDate: DateTime.create('2026-01-05'), cycleStartDate: DateTime.create('2026-01-05'),
checkInRadiusMeters: 100, checkInRadiusMeters: 100,
gpsIntervalSeconds: 5,
checkoutWarningRadiusMeters: 200,
status: Status.create('draft'), status: Status.create('draft'),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@@ -48,6 +50,8 @@ describe('CompanySettingsService', () => {
const result = await service.get(); const result = await service.get();
expect(result.cycleStartDate).toBe(sample.cycleStartDate.value); expect(result.cycleStartDate).toBe(sample.cycleStartDate.value);
expect(result.checkInRadiusMeters).toBe(100); expect(result.checkInRadiusMeters).toBe(100);
expect(result.gpsIntervalSeconds).toBe(5);
expect(result.checkoutWarningRadiusMeters).toBe(200);
}); });
it('update persists start of day', async () => { it('update persists start of day', async () => {
@@ -66,6 +70,21 @@ describe('CompanySettingsService', () => {
).rejects.toBeInstanceOf(BadRequestException); ).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 () => { it('update rejects when settings are missing and no cycle date provided', async () => {
repository.find.mockResolvedValue(null); repository.find.mockResolvedValue(null);
await expect(service.update({}, 'user-1')).rejects.toBeInstanceOf( await expect(service.update({}, 'user-1')).rejects.toBeInstanceOf(
@@ -26,6 +26,8 @@ export class CompanySettingsService {
input: { input: {
cycleStartDate?: string; cycleStartDate?: string;
checkInRadiusMeters?: number; checkInRadiusMeters?: number;
gpsIntervalSeconds?: number;
checkoutWarningRadiusMeters?: number;
}, },
userId: string, userId: string,
): Promise<ReturnType<CompanySettingsService['toItem']>> { ): Promise<ReturnType<CompanySettingsService['toItem']>> {
@@ -42,9 +44,24 @@ export class CompanySettingsService {
) { ) {
throw new BadRequestException('Invalid check-in radius'); 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({ const saved = await this.companySettingsRepository.upsert({
cycleStartDate, cycleStartDate,
checkInRadiusMeters: input.checkInRadiusMeters, checkInRadiusMeters: input.checkInRadiusMeters,
gpsIntervalSeconds: input.gpsIntervalSeconds,
checkoutWarningRadiusMeters: input.checkoutWarningRadiusMeters,
userId, userId,
}); });
return this.toItem(saved); return this.toItem(saved);
@@ -66,11 +83,27 @@ export class CompanySettingsService {
return setting.cycleStartDate; 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) { toItem(setting: CompanySetting) {
return { return {
id: setting.id, id: setting.id,
cycleStartDate: setting.cycleStartDate.value, cycleStartDate: setting.cycleStartDate.value,
checkInRadiusMeters: setting.checkInRadiusMeters, checkInRadiusMeters: setting.checkInRadiusMeters,
gpsIntervalSeconds: setting.gpsIntervalSeconds,
checkoutWarningRadiusMeters: setting.checkoutWarningRadiusMeters,
status: setting.status.value, status: setting.status.value,
createdAt: setting.createdAt.value, createdAt: setting.createdAt.value,
updatedAt: setting.updatedAt.value, updatedAt: setting.updatedAt.value,
@@ -25,6 +25,20 @@ export class UpdateCompanySettingDto {
@Min(1) @Min(1)
@Max(10_000) @Max(10_000)
checkInRadiusMeters?: number; 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 { export class CompanySettingDto {
@@ -37,6 +51,12 @@ export class CompanySettingDto {
@ApiProperty({ example: 100 }) @ApiProperty({ example: 100 })
checkInRadiusMeters!: number; checkInRadiusMeters!: number;
@ApiProperty({ example: 5 })
gpsIntervalSeconds!: number;
@ApiProperty({ example: 200 })
checkoutWarningRadiusMeters!: number;
@ApiProperty() @ApiProperty()
status!: string; status!: string;
@@ -30,6 +30,9 @@ export const ADMIN_LOGISTICS_PLAN_PRIVILEGE_KEY =
export const MOBILE_SALES_PLAN_PRIVILEGE_KEY = 'MOBILE.SALES.PLAN'; export const MOBILE_SALES_PLAN_PRIVILEGE_KEY = 'MOBILE.SALES.PLAN';
export const MOBILE_LOGISTICS_PLAN_PRIVILEGE_KEY = 'MOBILE.LOGISTICS.PLAN'; export const MOBILE_LOGISTICS_PLAN_PRIVILEGE_KEY = 'MOBILE.LOGISTICS.PLAN';
export const SETTINGS_PRIVILEGE_KEY = 'ADMIN.SETTINGS.DATA.SETTING'; 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_ATTENDANCE_PRIVILEGE_KEY = 'MOBILE.SALES.PLAN.ATTENDANCE';
export const FIELD_VISIT_PRIVILEGE_KEY = 'MOBILE.SALES.VISIT'; export const FIELD_VISIT_PRIVILEGE_KEY = 'MOBILE.SALES.VISIT';
@@ -0,0 +1,149 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayNotEmpty,
IsArray,
IsIn,
IsInt,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
IsUUID,
Matches,
Max,
Min,
ValidateNested,
} from 'class-validator';
import {
DefaultRelationDto,
PaginationMetaDto,
} from '../../../../common/http/response';
import { TIMELINE_ACTIVITY_TYPES } from '../../../../database/timeline-activities-table';
export class TimelineConfigDto {
@ApiProperty({ example: 5 })
gpsIntervalSeconds!: number;
@ApiProperty({ example: 200 })
checkoutWarningRadiusMeters!: number;
}
export class TimelineFootprintPointDto {
@ApiProperty({ example: -6.2 })
@IsNumber()
@Min(-90)
@Max(90)
latitude!: number;
@ApiProperty({ example: 106.8 })
@IsNumber()
@Min(-180)
@Max(180)
longitude!: number;
@ApiProperty({ description: 'Unix ms when the point was recorded' })
@IsInt()
@Min(1)
recordedAt!: number;
}
export class IngestTimelineFootprintsDto {
@ApiProperty({ type: [TimelineFootprintPointDto] })
@IsArray()
@ArrayNotEmpty()
@ArrayMaxSize(100)
@ValidateNested({ each: true })
@Type(() => TimelineFootprintPointDto)
points!: TimelineFootprintPointDto[];
}
export class IngestTimelineFootprintsResultDto {
@ApiProperty()
inserted!: number;
}
export class ListTimelineQueryDto {
@ApiPropertyOptional({ example: '2026-09-01', description: 'YYYY-MM-DD' })
@IsOptional()
@IsString()
@IsNotEmpty()
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'date must be YYYY-MM-DD' })
date?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID('4')
employeeId?: string;
}
export class TimelineFootprintDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ type: DefaultRelationDto })
employee!: DefaultRelationDto;
@ApiProperty()
latitude!: number;
@ApiProperty()
longitude!: number;
@ApiProperty()
recordedAt!: number;
}
export class TimelineActivityDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ type: DefaultRelationDto })
employee!: DefaultRelationDto;
@ApiProperty({ type: DefaultRelationDto, nullable: true })
customer!: DefaultRelationDto | null;
@ApiProperty({ format: 'uuid', nullable: true })
visitId!: string | null;
@ApiProperty({ enum: TIMELINE_ACTIVITY_TYPES })
type!: string;
@ApiProperty()
sourceType!: string;
@ApiProperty({ format: 'uuid' })
sourceId!: string;
@ApiProperty()
latitude!: number;
@ApiProperty()
longitude!: number;
@ApiProperty()
recordedAt!: number;
}
export class TimelineDayDto {
@ApiProperty()
date!: string;
@ApiProperty({ type: [TimelineFootprintDto] })
footprints!: TimelineFootprintDto[];
@ApiProperty({ type: [TimelineActivityDto] })
activities!: TimelineActivityDto[];
}
export class TimelineMeDto {
@ApiProperty()
date!: string;
@ApiProperty({ type: [TimelineActivityDto] })
activities!: TimelineActivityDto[];
}
void PaginationMetaDto;
@@ -0,0 +1,130 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, eq, gte, isNull, lte, sql } from 'drizzle-orm';
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
import { customers } from '../../../database/customers-table';
import {
timelineActivities,
type TimelineActivityRow,
type TimelineActivityType,
} from '../../../database/timeline-activities-table';
import { visits } from '../../../database/visits-table';
import { employees } from '../../../database/schema';
import type {
ListTimelineFilters,
RecordTimelineActivityInput,
TimelineActivity,
} from './timeline.types';
@Injectable()
export class TimelineActivitiesRepository {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
async insert(input: RecordTimelineActivityInput): Promise<TimelineActivity> {
const recordedAt = input.recordedAt ?? Date.now();
const inserted = await this.db
.insert(timelineActivities)
.values({
employeeId: input.employeeId,
customerId: input.customerId ?? null,
visitId: input.visitId ?? null,
type: input.type,
sourceType: input.sourceType,
sourceId: input.sourceId,
latitude: input.latitude,
longitude: input.longitude,
recordedAt,
})
.returning();
const row = inserted[0];
return {
id: row.id,
employeeId: row.employeeId,
customerId: row.customerId,
visitId: row.visitId,
type: row.type as TimelineActivityType,
sourceType: row.sourceType,
sourceId: row.sourceId,
latitude: row.latitude,
longitude: row.longitude,
recordedAt: row.recordedAt,
};
}
async findOpenVisitForEmployee(
employeeId: string,
): Promise<{ visitId: string; customerId: string } | null> {
const rows = await this.db
.select({
visitId: visits.id,
customerId: visits.customerId,
})
.from(visits)
.where(
and(
eq(visits.employeeId, employeeId),
isNull(visits.checkOutAt),
sql`${visits.status} <> 'archived'`,
),
)
.limit(1);
const row = rows[0];
return row ? { visitId: row.visitId, customerId: row.customerId } : null;
}
async list(filters: ListTimelineFilters): Promise<TimelineActivity[]> {
const conditions = [
gte(timelineActivities.recordedAt, filters.dayStartMs),
lte(timelineActivities.recordedAt, filters.dayEndMs),
];
if (filters.employeeId) {
conditions.push(eq(timelineActivities.employeeId, filters.employeeId));
}
const rows = await this.db
.select({
activity: timelineActivities,
employee: employees,
customer: customers,
})
.from(timelineActivities)
.innerJoin(employees, eq(timelineActivities.employeeId, employees.id))
.leftJoin(customers, eq(timelineActivities.customerId, customers.id))
.where(and(...conditions))
.orderBy(timelineActivities.recordedAt);
return rows.map((row) =>
this.toDomain(row.activity, row.employee, row.customer),
);
}
private toDomain(
row: TimelineActivityRow,
employee?: typeof employees.$inferSelect,
customer?: typeof customers.$inferSelect | null,
): TimelineActivity {
return {
id: row.id,
employeeId: row.employeeId,
customerId: row.customerId,
visitId: row.visitId,
type: row.type as TimelineActivityType,
sourceType: row.sourceType,
sourceId: row.sourceId,
latitude: row.latitude,
longitude: row.longitude,
recordedAt: row.recordedAt,
...(employee
? {
employee: {
id: employee.id,
code: employee.code,
name: employee.name,
},
}
: {}),
customer: customer
? { id: customer.id, code: customer.code, name: customer.name }
: null,
};
}
}
@@ -0,0 +1,61 @@
import { Injectable } from '@nestjs/common';
import type { TimelineActivityType } from '../../../database/timeline-activities-table';
import { TimelineActivitiesRepository } from './timeline-activities.repository';
import type { RecordTimelineActivityInput } from './timeline.types';
@Injectable()
export class TimelineActivitiesService {
constructor(
private readonly timelineActivitiesRepository: TimelineActivitiesRepository,
) {}
async record(input: RecordTimelineActivityInput): Promise<void> {
const openVisit =
input.visitId === undefined && input.customerId === undefined
? await this.timelineActivitiesRepository.findOpenVisitForEmployee(
input.employeeId,
)
: null;
await this.timelineActivitiesRepository.insert({
...input,
visitId: input.visitId ?? openVisit?.visitId ?? null,
customerId:
input.customerId ??
openVisit?.customerId ??
(input.type === 'customer_created' ? input.sourceId : null),
});
}
async recordIfLocated(input: {
readonly employeeId: string;
readonly type: TimelineActivityType;
readonly sourceType: string;
readonly sourceId: string;
readonly latitude?: number | null;
readonly longitude?: number | null;
readonly recordedAt?: number;
readonly customerId?: string | null;
readonly visitId?: string | null;
}): Promise<void> {
if (
input.latitude === undefined ||
input.latitude === null ||
input.longitude === undefined ||
input.longitude === null
) {
return;
}
await this.record({
employeeId: input.employeeId,
type: input.type,
sourceType: input.sourceType,
sourceId: input.sourceId,
latitude: input.latitude,
longitude: input.longitude,
recordedAt: input.recordedAt,
customerId: input.customerId,
visitId: input.visitId,
});
}
}
@@ -0,0 +1,82 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, eq, gte, lte } from 'drizzle-orm';
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
import {
timelineFootprints,
type TimelineFootprintRow,
} from '../../../database/timeline-footprints-table';
import { employees } from '../../../database/schema';
import type {
IngestFootprintPoint,
ListTimelineFilters,
TimelineFootprint,
} from './timeline.types';
@Injectable()
export class TimelineFootprintsRepository {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
async insertMany(
employeeId: string,
points: readonly IngestFootprintPoint[],
): Promise<number> {
if (points.length === 0) {
return 0;
}
const rows = points.map((point) => ({
employeeId,
latitude: point.latitude,
longitude: point.longitude,
recordedAt: point.recordedAt,
}));
const inserted = await this.db
.insert(timelineFootprints)
.values(rows)
.returning({ id: timelineFootprints.id });
return inserted.length;
}
async list(filters: ListTimelineFilters): Promise<TimelineFootprint[]> {
const conditions = [
gte(timelineFootprints.recordedAt, filters.dayStartMs),
lte(timelineFootprints.recordedAt, filters.dayEndMs),
];
if (filters.employeeId) {
conditions.push(eq(timelineFootprints.employeeId, filters.employeeId));
}
const rows = await this.db
.select({
footprint: timelineFootprints,
employee: employees,
})
.from(timelineFootprints)
.innerJoin(employees, eq(timelineFootprints.employeeId, employees.id))
.where(and(...conditions))
.orderBy(timelineFootprints.recordedAt);
return rows.map((row) => this.toDomain(row.footprint, row.employee));
}
private toDomain(
row: TimelineFootprintRow,
employee?: typeof employees.$inferSelect,
): TimelineFootprint {
return {
id: row.id,
employeeId: row.employeeId,
latitude: row.latitude,
longitude: row.longitude,
recordedAt: row.recordedAt,
...(employee
? {
employee: {
id: employee.id,
code: employee.code,
name: employee.name,
},
}
: {}),
};
}
}
@@ -0,0 +1,67 @@
import { Controller, Get, Query } from '@nestjs/common';
import {
ApiBearerAuth,
ApiForbiddenResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
import {
ADMIN_SALES_TIMELINE_PRIVILEGE_KEY,
FIELD_ATTENDANCE_PRIVILEGE_KEY,
MOBILE_SALES_TIMELINE_PRIVILEGE_KEY,
} from '../shared/field-purpose';
import {
ListTimelineQueryDto,
TimelineConfigDto,
TimelineDayDto,
TimelineMeDto,
} from './dto/timeline.dto';
import { TimelineService } from './timeline.service';
@ApiTags('timeline')
@ApiBearerAuth(BEARER_AUTH_NAME)
@Controller('timeline')
export class TimelineReadController {
constructor(private readonly timelineService: TimelineService) {}
@Get('config')
@RequirePrivilege(
[MOBILE_SALES_TIMELINE_PRIVILEGE_KEY, FIELD_ATTENDANCE_PRIVILEGE_KEY],
'view',
)
@ApiOperation({ summary: 'Get timeline tracking configuration' })
@ApiOkResponse({ type: TimelineConfigDto })
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
config(): Promise<TimelineConfigDto> {
return this.timelineService.getConfig();
}
@Get('me')
@RequirePrivilege(MOBILE_SALES_TIMELINE_PRIVILEGE_KEY, 'view')
@ApiOperation({ summary: 'Get current user timeline activities for a day' })
@ApiOkResponse({ type: TimelineMeDto })
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
me(
@Query() query: ListTimelineQueryDto,
@CurrentUser('id') userId: string,
): Promise<TimelineMeDto> {
return this.timelineService.getMyDay(query, userId);
}
@Get()
@RequirePrivilege(ADMIN_SALES_TIMELINE_PRIVILEGE_KEY, 'view')
@ApiOperation({ summary: 'Get timeline footprints and activities for a day' })
@ApiOkResponse({ type: TimelineDayDto })
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
day(@Query() query: ListTimelineQueryDto): Promise<TimelineDayDto> {
return this.timelineService.getDay(query);
}
}
@@ -0,0 +1,38 @@
import { Body, Controller, Post } from '@nestjs/common';
import {
ApiBearerAuth,
ApiCreatedResponse,
ApiForbiddenResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
import { MOBILE_SALES_TIMELINE_PRIVILEGE_KEY } from '../shared/field-purpose';
import {
IngestTimelineFootprintsDto,
IngestTimelineFootprintsResultDto,
} from './dto/timeline.dto';
import { TimelineService } from './timeline.service';
@ApiTags('timeline')
@ApiBearerAuth(BEARER_AUTH_NAME)
@Controller('timeline')
export class TimelineWriteController {
constructor(private readonly timelineService: TimelineService) {}
@Post('footprints')
@RequirePrivilege(MOBILE_SALES_TIMELINE_PRIVILEGE_KEY, 'create')
@ApiOperation({ summary: 'Ingest GPS footprint points' })
@ApiCreatedResponse({ type: IngestTimelineFootprintsResultDto })
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
ingestFootprints(
@Body() dto: IngestTimelineFootprintsDto,
@CurrentUser('id') userId: string,
): Promise<IngestTimelineFootprintsResultDto> {
return this.timelineService.ingestFootprints(dto, userId);
}
}
@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { EmployeesModule } from '../../configuration/employees/employees.module';
import { CompanySettingsRepository } from '../settings/company-settings.repository';
import { CompanySettingsService } from '../settings/company-settings.service';
import { TimelineActivitiesRepository } from './timeline-activities.repository';
import { TimelineActivitiesService } from './timeline-activities.service';
import { TimelineFootprintsRepository } from './timeline-footprints.repository';
import { TimelineReadController } from './timeline-read.controller';
import { TimelineWriteController } from './timeline-write.controller';
import { TimelineService } from './timeline.service';
@Module({
imports: [EmployeesModule],
controllers: [TimelineReadController, TimelineWriteController],
providers: [
CompanySettingsRepository,
CompanySettingsService,
TimelineFootprintsRepository,
TimelineActivitiesRepository,
TimelineActivitiesService,
TimelineService,
],
exports: [TimelineActivitiesService, TimelineService],
})
export class TimelineModule {}
@@ -0,0 +1,121 @@
import { BadRequestException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
import { EmployeesService } from '../../configuration/employees/employees.service';
import type { CompanySetting } from '../settings/company-setting';
import { CompanySettingsService } from '../settings/company-settings.service';
import { TimelineActivitiesRepository } from './timeline-activities.repository';
import { TimelineFootprintsRepository } from './timeline-footprints.repository';
import { TimelineService } from './timeline.service';
describe('TimelineService', () => {
let service: TimelineService;
let footprintsRepository: jest.Mocked<
Pick<TimelineFootprintsRepository, 'insertMany' | 'list'>
>;
let activitiesRepository: jest.Mocked<Pick<TimelineActivitiesRepository, 'list'>>;
let employeesService: jest.Mocked<Pick<EmployeesService, 'requireByUserId'>>;
let companySettingsService: jest.Mocked<
Pick<CompanySettingsService, 'requireTimelineConfig'>
>;
const nowMs = DateTime.fromUnixMs(Date.now()).startOfDay().value + 3_600_000;
beforeEach(async () => {
footprintsRepository = {
insertMany: jest.fn(),
list: jest.fn(),
};
activitiesRepository = {
list: jest.fn(),
};
employeesService = {
requireByUserId: jest.fn(),
};
companySettingsService = {
requireTimelineConfig: jest.fn(),
};
const moduleRef: TestingModule = await Test.createTestingModule({
providers: [
TimelineService,
{ provide: TimelineFootprintsRepository, useValue: footprintsRepository },
{ provide: TimelineActivitiesRepository, useValue: activitiesRepository },
{ provide: EmployeesService, useValue: employeesService },
{ provide: CompanySettingsService, useValue: companySettingsService },
],
}).compile();
service = moduleRef.get(TimelineService);
companySettingsService.requireTimelineConfig.mockResolvedValue({
gpsIntervalSeconds: 5,
checkoutWarningRadiusMeters: 200,
});
});
it('returns timeline config from company settings', async () => {
await expect(service.getConfig()).resolves.toEqual({
gpsIntervalSeconds: 5,
checkoutWarningRadiusMeters: 200,
});
});
it('ingests footprint points for the current employee', async () => {
employeesService.requireByUserId.mockResolvedValue({
id: 'emp-1',
} as Awaited<ReturnType<EmployeesService['requireByUserId']>>);
footprintsRepository.insertMany.mockResolvedValue(1);
const result = await service.ingestFootprints(
{
points: [{ latitude: -6.2, longitude: 106.8, recordedAt: nowMs }],
},
'user-1',
);
expect(result).toEqual({ inserted: 1 });
expect(footprintsRepository.insertMany).toHaveBeenCalledWith('emp-1', [
{ latitude: -6.2, longitude: 106.8, recordedAt: nowMs },
]);
});
it('rejects stale footprint timestamps', async () => {
employeesService.requireByUserId.mockResolvedValue({
id: 'emp-1',
} as Awaited<ReturnType<EmployeesService['requireByUserId']>>);
await expect(
service.ingestFootprints(
{
points: [
{
latitude: -6.2,
longitude: 106.8,
recordedAt: Date.now() - 25 * 60 * 60 * 1000,
},
],
},
'user-1',
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('downsamples footprints when querying all employees', async () => {
const footprints = Array.from({ length: 4000 }, (_, index) => ({
id: `fp-${index}`,
employeeId: 'emp-1',
latitude: -6.2,
longitude: 106.8 + index * 0.0001,
recordedAt: nowMs + index * 1000,
employee: { id: 'emp-1', code: 'E1', name: 'Ada' },
}));
footprintsRepository.list.mockResolvedValue(footprints);
activitiesRepository.list.mockResolvedValue([]);
const result = await service.getDay({ date: '2026-09-01' });
expect(result.footprints.length).toBeLessThanOrEqual(2000);
expect(result.activities).toEqual([]);
});
});
@@ -0,0 +1,179 @@
import {
BadRequestException,
Injectable,
} from '@nestjs/common';
import {
DEFAULT_RELATION_FIELDS,
pickRelation,
} from '../../../common/http/response';
import { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { EmployeesService } from '../../configuration/employees/employees.service';
import { CompanySettingsService } from '../settings/company-settings.service';
import type {
IngestTimelineFootprintsDto,
ListTimelineQueryDto,
TimelineActivityDto,
TimelineConfigDto,
TimelineDayDto,
TimelineFootprintDto,
TimelineMeDto,
} from './dto/timeline.dto';
import { TimelineActivitiesRepository } from './timeline-activities.repository';
import { TimelineFootprintsRepository } from './timeline-footprints.repository';
import type { TimelineActivity, TimelineFootprint } from './timeline.types';
const MS_PER_DAY = 86_400_000;
const MAX_FOOTPRINTS_ALL_EMPLOYEES = 2000;
const MAX_FOOTPRINT_AGE_MS = 24 * 60 * 60 * 1000;
@Injectable()
export class TimelineService {
constructor(
private readonly timelineFootprintsRepository: TimelineFootprintsRepository,
private readonly timelineActivitiesRepository: TimelineActivitiesRepository,
private readonly employeesService: EmployeesService,
private readonly companySettingsService: CompanySettingsService,
) {}
async getConfig(): Promise<TimelineConfigDto> {
return this.companySettingsService.requireTimelineConfig();
}
async ingestFootprints(
dto: IngestTimelineFootprintsDto,
userId: string,
): Promise<{ inserted: number }> {
const employee = await this.employeesService.requireByUserId(userId);
const now = Date.now();
const points = dto.points.map((point) => {
this.assertCoordinates(point.latitude, point.longitude);
if (now - point.recordedAt > MAX_FOOTPRINT_AGE_MS) {
throw new BadRequestException('Footprint timestamp is too old');
}
if (point.recordedAt > now + 60_000) {
throw new BadRequestException('Footprint timestamp is in the future');
}
return point;
});
const inserted = await this.timelineFootprintsRepository.insertMany(
employee.id,
points,
);
return { inserted };
}
async getDay(query: ListTimelineQueryDto): Promise<TimelineDayDto> {
const { dateLabel, filters } = this.resolveDayFilters(query);
const [footprints, activities] = await Promise.all([
this.timelineFootprintsRepository.list(filters),
this.timelineActivitiesRepository.list(filters),
]);
return {
date: dateLabel,
footprints: this.downsampleFootprints(footprints, query.employeeId).map(
(item) => this.toFootprintDto(item),
),
activities: activities.map((item) => this.toActivityDto(item)),
};
}
async getMyDay(
query: ListTimelineQueryDto,
userId: string,
): Promise<TimelineMeDto> {
const employee = await this.employeesService.requireByUserId(userId);
const { dateLabel, filters } = this.resolveDayFilters({
...query,
employeeId: employee.id,
});
const activities = await this.timelineActivitiesRepository.list(filters);
return {
date: dateLabel,
activities: activities.map((item) => this.toActivityDto(item)),
};
}
private resolveDayFilters(query: ListTimelineQueryDto): {
dateLabel: string;
filters: {
dayStartMs: number;
dayEndMs: number;
employeeId?: string;
};
} {
const dateLabel = this.resolveDateLabel(query.date);
const dayStart = DateTime.create(dateLabel).startOfDay();
return {
dateLabel,
filters: {
dayStartMs: dayStart.value,
dayEndMs: dayStart.value + MS_PER_DAY - 1,
employeeId: query.employeeId,
},
};
}
private resolveDateLabel(raw?: string): string {
if (!raw) {
return DateTime.fromUnixMs(Date.now()).startOfDay().format().slice(0, 10);
}
try {
DateTime.create(raw);
return raw;
} catch (error) {
if (error instanceof InvalidDateTimeError) {
throw new BadRequestException('Invalid date');
}
throw error;
}
}
private downsampleFootprints(
footprints: readonly TimelineFootprint[],
employeeId?: string,
): TimelineFootprint[] {
if (employeeId || footprints.length <= MAX_FOOTPRINTS_ALL_EMPLOYEES) {
return [...footprints];
}
const stride = Math.ceil(
footprints.length / MAX_FOOTPRINTS_ALL_EMPLOYEES,
);
return footprints.filter((_, index) => index % stride === 0);
}
private assertCoordinates(latitude: number, longitude: number): void {
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
throw new BadRequestException('Invalid coordinates');
}
}
private toFootprintDto(footprint: TimelineFootprint): TimelineFootprintDto {
return {
id: footprint.id,
employee: pickRelation(footprint.employee, DEFAULT_RELATION_FIELDS)!,
latitude: footprint.latitude,
longitude: footprint.longitude,
recordedAt: footprint.recordedAt,
};
}
private toActivityDto(activity: TimelineActivity): TimelineActivityDto {
return {
id: activity.id,
employee: pickRelation(activity.employee, DEFAULT_RELATION_FIELDS)!,
customer: activity.customer
? pickRelation(activity.customer, DEFAULT_RELATION_FIELDS)
: null,
visitId: activity.visitId,
type: activity.type,
sourceType: activity.sourceType,
sourceId: activity.sourceId,
latitude: activity.latitude,
longitude: activity.longitude,
recordedAt: activity.recordedAt,
};
}
}
@@ -0,0 +1,61 @@
import type { TimelineActivityType } from '../../../database/timeline-activities-table';
export type TimelineFootprint = {
readonly id: string;
readonly employeeId: string;
readonly latitude: number;
readonly longitude: number;
readonly recordedAt: number;
readonly employee?: {
readonly id: string;
readonly code: string;
readonly name: string;
};
};
export type TimelineActivity = {
readonly id: string;
readonly employeeId: string;
readonly customerId: string | null;
readonly visitId: string | null;
readonly type: TimelineActivityType;
readonly sourceType: string;
readonly sourceId: string;
readonly latitude: number;
readonly longitude: number;
readonly recordedAt: number;
readonly employee?: {
readonly id: string;
readonly code: string;
readonly name: string;
};
readonly customer?: {
readonly id: string;
readonly code: string;
readonly name: string;
} | null;
};
export type RecordTimelineActivityInput = {
readonly employeeId: string;
readonly type: TimelineActivityType;
readonly sourceType: string;
readonly sourceId: string;
readonly latitude: number;
readonly longitude: number;
readonly recordedAt?: number;
readonly customerId?: string | null;
readonly visitId?: string | null;
};
export type IngestFootprintPoint = {
readonly latitude: number;
readonly longitude: number;
readonly recordedAt: number;
};
export type ListTimelineFilters = {
readonly dayStartMs: number;
readonly dayEndMs: number;
readonly employeeId?: string;
};
@@ -23,6 +23,7 @@ import {
type CheckInPayload, type CheckInPayload,
} from '../shared/check-in-verification'; } from '../shared/check-in-verification';
import { FIELD_VISIT_PRIVILEGE_KEY } from '../shared/field-purpose'; import { FIELD_VISIT_PRIVILEGE_KEY } from '../shared/field-purpose';
import { TimelineActivitiesService } from '../timeline/timeline-activities.service';
import type { Visit } from './visit'; import type { Visit } from './visit';
import type { import type {
ListVisitsQueryDto, ListVisitsQueryDto,
@@ -40,6 +41,7 @@ export class VisitsService {
private readonly customersService: CustomersService, private readonly customersService: CustomersService,
private readonly employeesService: EmployeesService, private readonly employeesService: EmployeesService,
private readonly companySettingsService: CompanySettingsService, private readonly companySettingsService: CompanySettingsService,
private readonly timelineActivitiesService: TimelineActivitiesService,
) {} ) {}
async list(query: ListVisitsQueryDto): Promise<PaginationResponse<VisitDto>> { async list(query: ListVisitsQueryDto): Promise<PaginationResponse<VisitDto>> {
@@ -131,6 +133,17 @@ export class VisitsService {
checkInDistanceMeters: verified.distanceMeters, checkInDistanceMeters: verified.distanceMeters,
userId, 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); return this.toItem(created);
} }
@@ -175,6 +188,17 @@ export class VisitsService {
checkOutDistanceMeters: verified.distanceMeters, checkOutDistanceMeters: verified.distanceMeters,
userId, 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); return this.toItem(updated);
} }
@@ -4,6 +4,7 @@ import { CustomersModule } from '../../configuration/customers/customers.module'
import { DivisionsModule } from '../../configuration/divisions/divisions.module'; import { DivisionsModule } from '../../configuration/divisions/divisions.module';
import { EmployeesModule } from '../../configuration/employees/employees.module'; import { EmployeesModule } from '../../configuration/employees/employees.module';
import { ProductsModule } from '../../configuration/products/products.module'; import { ProductsModule } from '../../configuration/products/products.module';
import { TimelineModule } from '../../field/timeline/timeline.module';
import { SalesRequestsModule } from '../sales-requests/sales-requests.module'; import { SalesRequestsModule } from '../sales-requests/sales-requests.module';
import { DocumentCodeService } from '../shared/document-code.service'; import { DocumentCodeService } from '../shared/document-code.service';
import { SalesDocumentFlowModule } from '../shared/sales-document-flow.module'; import { SalesDocumentFlowModule } from '../shared/sales-document-flow.module';
@@ -20,6 +21,7 @@ import { SalesOrdersService } from './sales-orders.service';
CustomersModule, CustomersModule,
ProductsModule, ProductsModule,
SalesRequestsModule, SalesRequestsModule,
TimelineModule,
forwardRef(() => SalesDocumentFlowModule), forwardRef(() => SalesDocumentFlowModule),
], ],
controllers: [SalesOrdersReadController, SalesOrdersWriteController], controllers: [SalesOrdersReadController, SalesOrdersWriteController],
@@ -23,6 +23,7 @@ import { CustomersService } from '../../configuration/customers/customers.servic
import { DivisionsService } from '../../configuration/divisions/divisions.service'; import { DivisionsService } from '../../configuration/divisions/divisions.service';
import { EmployeesService } from '../../configuration/employees/employees.service'; import { EmployeesService } from '../../configuration/employees/employees.service';
import { ProductsService } from '../../configuration/products/products.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 { SalesRequestsService } from '../sales-requests/sales-requests.service';
import { import {
isValidDocumentAddress, isValidDocumentAddress,
@@ -95,6 +96,7 @@ export class SalesOrdersService {
private readonly salesRequestsService: SalesRequestsService, private readonly salesRequestsService: SalesRequestsService,
@Inject(forwardRef(() => SalesDocumentFlowService)) @Inject(forwardRef(() => SalesDocumentFlowService))
private readonly salesDocumentFlowService: SalesDocumentFlowService, private readonly salesDocumentFlowService: SalesDocumentFlowService,
private readonly timelineActivitiesService: TimelineActivitiesService,
) {} ) {}
async list( async list(
@@ -152,6 +154,14 @@ export class SalesOrdersService {
const created = await this.salesOrdersRepository.create( const created = await this.salesOrdersRepository.create(
await this.toCreateInput(merged), 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); return this.toDetail(created);
} }
@@ -5,11 +5,14 @@ import {
IsArray, IsArray,
IsIn, IsIn,
IsNotEmpty, IsNotEmpty,
IsNumber,
IsOptional, IsOptional,
IsString, IsString,
IsUUID, IsUUID,
Matches, Matches,
Max,
MaxLength, MaxLength,
Min,
ValidateNested, ValidateNested,
} from 'class-validator'; } from 'class-validator';
@@ -89,6 +92,20 @@ export class CreateSalesPaymentDto {
@IsOptional() @IsOptional()
@IsIn([...SALES_PAYMENT_STATUSES]) @IsIn([...SALES_PAYMENT_STATUSES])
status?: string; 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 { export class UpdateSalesPaymentDto {
@@ -134,6 +134,8 @@ export class SalesPaymentsWriteController {
invoices: dto.invoices, invoices: dto.invoices,
images: dto.images, images: dto.images,
status: dto.status, status: dto.status,
latitude: dto.latitude,
longitude: dto.longitude,
userId, userId,
}); });
} }
@@ -1,4 +1,6 @@
import { Module } from '@nestjs/common'; 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 { SalesInvoicesModule } from '../sales-invoices/sales-invoices.module';
import { DocumentCodeService } from '../shared/document-code.service'; import { DocumentCodeService } from '../shared/document-code.service';
import { SalesPaymentsReadController } from './sales-payments-read.controller'; import { SalesPaymentsReadController } from './sales-payments-read.controller';
@@ -7,7 +9,7 @@ import { SalesPaymentsRepository } from './sales-payments.repository';
import { SalesPaymentsService } from './sales-payments.service'; import { SalesPaymentsService } from './sales-payments.service';
@Module({ @Module({
imports: [SalesInvoicesModule], imports: [SalesInvoicesModule, EmployeesModule, TimelineModule],
controllers: [SalesPaymentsReadController, SalesPaymentsWriteController], controllers: [SalesPaymentsReadController, SalesPaymentsWriteController],
providers: [ providers: [
DocumentCodeService, DocumentCodeService,
@@ -14,6 +14,8 @@ import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { Decimal } from '../../../common/value-objects/decimal/decimal';
import { InvalidDecimalError } from '../../../common/value-objects/decimal/invalid-decimal.error'; import { InvalidDecimalError } from '../../../common/value-objects/decimal/invalid-decimal.error';
import { Status } from '../../../common/value-objects/status/status'; 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 { SalesInvoicesService } from '../sales-invoices/sales-invoices.service';
import { import {
isValidDocumentCode, isValidDocumentCode,
@@ -65,6 +67,8 @@ export class SalesPaymentsService {
constructor( constructor(
private readonly salesPaymentsRepository: SalesPaymentsRepository, private readonly salesPaymentsRepository: SalesPaymentsRepository,
private readonly salesInvoicesService: SalesInvoicesService, private readonly salesInvoicesService: SalesInvoicesService,
private readonly employeesService: EmployeesService,
private readonly timelineActivitiesService: TimelineActivitiesService,
) {} ) {}
async list( async list(
@@ -109,11 +113,22 @@ export class SalesPaymentsService {
invoices: PaymentAllocationBody[]; invoices: PaymentAllocationBody[];
images?: SalesImageBody[]; images?: SalesImageBody[];
status?: string; status?: string;
latitude?: number | null;
longitude?: number | null;
userId: string; userId: string;
}): Promise<ReturnType<SalesPaymentsService['toDetail']>> { }): Promise<ReturnType<SalesPaymentsService['toDetail']>> {
const created = await this.salesPaymentsRepository.create( const created = await this.salesPaymentsRepository.create(
await this.toCreateInput(input), 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); return this.toDetail(created);
} }
@@ -4,6 +4,7 @@ import { CustomersModule } from '../../configuration/customers/customers.module'
import { DivisionsModule } from '../../configuration/divisions/divisions.module'; import { DivisionsModule } from '../../configuration/divisions/divisions.module';
import { EmployeesModule } from '../../configuration/employees/employees.module'; import { EmployeesModule } from '../../configuration/employees/employees.module';
import { ProductsModule } from '../../configuration/products/products.module'; import { ProductsModule } from '../../configuration/products/products.module';
import { TimelineModule } from '../../field/timeline/timeline.module';
import { DocumentCodeService } from '../shared/document-code.service'; import { DocumentCodeService } from '../shared/document-code.service';
import { SalesRequestsReadController } from './sales-requests-read.controller'; import { SalesRequestsReadController } from './sales-requests-read.controller';
import { SalesRequestsWriteController } from './sales-requests-write.controller'; import { SalesRequestsWriteController } from './sales-requests-write.controller';
@@ -17,6 +18,7 @@ import { SalesRequestsService } from './sales-requests.service';
DivisionsModule, DivisionsModule,
CustomersModule, CustomersModule,
ProductsModule, ProductsModule,
TimelineModule,
], ],
controllers: [SalesRequestsReadController, SalesRequestsWriteController], controllers: [SalesRequestsReadController, SalesRequestsWriteController],
providers: [ providers: [
@@ -20,6 +20,7 @@ import { CustomersService } from '../../configuration/customers/customers.servic
import { DivisionsService } from '../../configuration/divisions/divisions.service'; import { DivisionsService } from '../../configuration/divisions/divisions.service';
import { EmployeesService } from '../../configuration/employees/employees.service'; import { EmployeesService } from '../../configuration/employees/employees.service';
import { ProductsService } from '../../configuration/products/products.service'; import { ProductsService } from '../../configuration/products/products.service';
import { TimelineActivitiesService } from '../../field/timeline/timeline-activities.service';
import { import {
isValidDocumentAddress, isValidDocumentAddress,
isValidDocumentCode, isValidDocumentCode,
@@ -84,6 +85,7 @@ export class SalesRequestsService {
private readonly divisionsService: DivisionsService, private readonly divisionsService: DivisionsService,
private readonly customersService: CustomersService, private readonly customersService: CustomersService,
private readonly productsService: ProductsService, private readonly productsService: ProductsService,
private readonly timelineActivitiesService: TimelineActivitiesService,
) {} ) {}
async list( async list(
@@ -141,6 +143,14 @@ export class SalesRequestsService {
const created = await this.salesRequestsRepository.create( const created = await this.salesRequestsRepository.create(
await this.toCreateInput(input), 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); return this.toDetail(created);
} }
+19
View File
@@ -107,4 +107,23 @@ describe('Company settings (e2e)', () => {
(updated.body as { cycleStartDate: number }).cycleStartDate, (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);
});
}); });
+183
View File
@@ -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<App>;
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');
});
});