Add field management module with database schema and validation

- Introduced `FieldModule` to manage cycles and plans, including read and write controllers.
- Created database migrations for `company_settings`, `cycles`, `cycle_weekdays`, `cycle_destinations`, `plans`, `plan_destinations`, `plan_invoices`, and `plan_packing_slips` tables, including constraints and unique indexes.
- Developed service and repository layers for handling cycle and plan data operations.
- Added unit tests for the cycles and plans services, repositories, and controllers to ensure functionality and correctness.
- Updated application module to include the new `FieldModule` for better organization.
This commit is contained in:
shancheas
2026-08-25 18:20:19 +07:00
parent 34d4f2c120
commit c9f9b31abf
53 changed files with 5990 additions and 5 deletions
+112
View File
@@ -0,0 +1,112 @@
CREATE TABLE "company_settings" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"cycle_start_date" bigint NOT NULL,
"status" text DEFAULT 'draft' NOT NULL,
"created_at" bigint NOT NULL,
"updated_at" bigint NOT NULL,
"created_by" uuid NOT NULL,
"updated_by" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cycles" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"employee_id" uuid NOT NULL,
"purpose" text NOT NULL,
"cycle_number" integer NOT NULL,
"status" text DEFAULT 'draft' NOT NULL,
"created_at" bigint NOT NULL,
"updated_at" bigint NOT NULL,
"created_by" uuid NOT NULL,
"updated_by" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cycle_weekdays" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"cycle_id" uuid NOT NULL,
"weekday" text NOT NULL,
"start_branch_id" uuid NOT NULL,
"end_branch_id" uuid NOT NULL,
"route_geometry" jsonb NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cycle_destinations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"cycle_weekday_id" uuid NOT NULL,
"customer_id" uuid NOT NULL,
"sort_order" integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE "plans" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"employee_id" uuid NOT NULL,
"purpose" text NOT NULL,
"date" bigint NOT NULL,
"start_branch_id" uuid NOT NULL,
"end_branch_id" uuid NOT NULL,
"route_geometry" jsonb NOT NULL,
"status" text DEFAULT 'draft' NOT NULL,
"created_at" bigint NOT NULL,
"updated_at" bigint NOT NULL,
"created_by" uuid NOT NULL,
"updated_by" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE "plan_destinations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"plan_id" uuid NOT NULL,
"customer_id" uuid NOT NULL,
"sort_order" integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE "plan_invoices" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"plan_id" uuid NOT NULL,
"invoice_id" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE "plan_packing_slips" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"plan_id" uuid NOT NULL,
"packing_slip_id" uuid NOT NULL
);
--> statement-breakpoint
ALTER TABLE "company_settings" ADD CONSTRAINT "company_settings_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "company_settings" ADD CONSTRAINT "company_settings_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cycles" ADD CONSTRAINT "cycles_employee_id_employees_id_fk" FOREIGN KEY ("employee_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cycles" ADD CONSTRAINT "cycles_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cycles" ADD CONSTRAINT "cycles_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cycle_weekdays" ADD CONSTRAINT "cycle_weekdays_cycle_id_cycles_id_fk" FOREIGN KEY ("cycle_id") REFERENCES "public"."cycles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cycle_weekdays" ADD CONSTRAINT "cycle_weekdays_start_branch_id_branches_id_fk" FOREIGN KEY ("start_branch_id") REFERENCES "public"."branches"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cycle_weekdays" ADD CONSTRAINT "cycle_weekdays_end_branch_id_branches_id_fk" FOREIGN KEY ("end_branch_id") REFERENCES "public"."branches"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cycle_destinations" ADD CONSTRAINT "cycle_destinations_cycle_weekday_id_cycle_weekdays_id_fk" FOREIGN KEY ("cycle_weekday_id") REFERENCES "public"."cycle_weekdays"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cycle_destinations" ADD CONSTRAINT "cycle_destinations_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plans" ADD CONSTRAINT "plans_employee_id_employees_id_fk" FOREIGN KEY ("employee_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plans" ADD CONSTRAINT "plans_start_branch_id_branches_id_fk" FOREIGN KEY ("start_branch_id") REFERENCES "public"."branches"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plans" ADD CONSTRAINT "plans_end_branch_id_branches_id_fk" FOREIGN KEY ("end_branch_id") REFERENCES "public"."branches"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plans" ADD CONSTRAINT "plans_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plans" ADD CONSTRAINT "plans_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plan_destinations" ADD CONSTRAINT "plan_destinations_plan_id_plans_id_fk" FOREIGN KEY ("plan_id") REFERENCES "public"."plans"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plan_destinations" ADD CONSTRAINT "plan_destinations_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plan_invoices" ADD CONSTRAINT "plan_invoices_plan_id_plans_id_fk" FOREIGN KEY ("plan_id") REFERENCES "public"."plans"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plan_invoices" ADD CONSTRAINT "plan_invoices_invoice_id_sales_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."sales_invoices"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plan_packing_slips" ADD CONSTRAINT "plan_packing_slips_plan_id_plans_id_fk" FOREIGN KEY ("plan_id") REFERENCES "public"."plans"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "plan_packing_slips" ADD CONSTRAINT "plan_packing_slips_packing_slip_id_packing_slips_id_fk" FOREIGN KEY ("packing_slip_id") REFERENCES "public"."packing_slips"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "cycles_employee_purpose_number_live_unique" ON "cycles" USING btree ("employee_id","purpose","cycle_number") WHERE "status" <> 'archived';--> statement-breakpoint
CREATE INDEX "cycles_employee_id_idx" ON "cycles" USING btree ("employee_id");--> statement-breakpoint
CREATE UNIQUE INDEX "cycle_weekdays_cycle_weekday_unique" ON "cycle_weekdays" USING btree ("cycle_id","weekday");--> statement-breakpoint
CREATE INDEX "cycle_weekdays_cycle_id_idx" ON "cycle_weekdays" USING btree ("cycle_id");--> statement-breakpoint
CREATE INDEX "cycle_destinations_weekday_id_idx" ON "cycle_destinations" USING btree ("cycle_weekday_id");--> statement-breakpoint
CREATE UNIQUE INDEX "plans_employee_purpose_date_live_unique" ON "plans" USING btree ("employee_id","purpose","date") WHERE "status" <> 'archived';--> statement-breakpoint
CREATE INDEX "plans_employee_id_idx" ON "plans" USING btree ("employee_id");--> statement-breakpoint
CREATE UNIQUE INDEX "plan_destinations_plan_customer_unique" ON "plan_destinations" USING btree ("plan_id","customer_id");--> statement-breakpoint
CREATE INDEX "plan_destinations_plan_id_idx" ON "plan_destinations" USING btree ("plan_id");--> statement-breakpoint
CREATE UNIQUE INDEX "plan_invoices_plan_invoice_unique" ON "plan_invoices" USING btree ("plan_id","invoice_id");--> statement-breakpoint
CREATE INDEX "plan_invoices_plan_id_idx" ON "plan_invoices" USING btree ("plan_id");--> statement-breakpoint
CREATE UNIQUE INDEX "plan_packing_slips_plan_slip_unique" ON "plan_packing_slips" USING btree ("plan_id","packing_slip_id");--> statement-breakpoint
CREATE INDEX "plan_packing_slips_plan_id_idx" ON "plan_packing_slips" USING btree ("plan_id");--> statement-breakpoint
INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES
('CONFIGURATION.SETTING', 'Company settings', 13),
('SALES.CYCLE', 'Sales cycles', 14),
('SALES.PLAN', 'Sales plans', 15),
('LOGISTICS.CYCLE', 'Logistics cycles', 16),
('LOGISTICS.PLAN', 'Logistics plans', 17);
+7
View File
@@ -78,6 +78,13 @@
"when": 1787558000000, "when": 1787558000000,
"tag": "0010_phase2_shape", "tag": "0010_phase2_shape",
"breakpoints": true "breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1787559000000,
"tag": "0011_field",
"breakpoints": true
} }
] ]
} }
+2
View File
@@ -6,6 +6,7 @@ import loadEnv from './config/env';
import { DatabaseModule } from './database/database.module'; import { DatabaseModule } from './database/database.module';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { ConfigurationModule } from './modules/configuration/configuration.module'; import { ConfigurationModule } from './modules/configuration/configuration.module';
import { FieldModule } from './modules/field/field.module';
import { SalesModule } from './modules/sales/sales.module'; import { SalesModule } from './modules/sales/sales.module';
import { PrivilegesModule } from './modules/privileges/privileges.module'; import { PrivilegesModule } from './modules/privileges/privileges.module';
import { UsersModule } from './modules/users/users.module'; import { UsersModule } from './modules/users/users.module';
@@ -22,6 +23,7 @@ import { UsersModule } from './modules/users/users.module';
PrivilegesModule, PrivilegesModule,
ConfigurationModule, ConfigurationModule,
SalesModule, SalesModule,
FieldModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],
@@ -92,8 +92,24 @@ describe('DateTime', () => {
expect(dt.value).toBe(Date.UTC(2026, 7, 20, 10, 0, 0, 0)); expect(dt.value).toBe(Date.UTC(2026, 7, 20, 10, 0, 0, 0));
}); });
it('rejects date-only strings', () => { it('parses date-only strings as start of day in DEFAULT_TIMEZONE (GMT+7)', () => {
expect(() => DateTime.create('2026-08-20')).toThrow(InvalidDateTimeError); delete process.env.DEFAULT_TIMEZONE;
const dt = DateTime.create('2026-08-20');
expect(dt.value).toBe(Date.UTC(2026, 7, 19, 17, 0, 0, 0));
});
it('parses date-only strings using a custom DEFAULT_TIMEZONE', () => {
process.env.DEFAULT_TIMEZONE = 'UTC+0';
const dt = DateTime.create('2026-08-20');
expect(dt.value).toBe(Date.UTC(2026, 7, 20, 0, 0, 0, 0));
});
it('rejects invalid calendar dates on date-only strings', () => {
expect(() => DateTime.create('2026-02-30')).toThrow(InvalidDateTimeError);
}); });
it('rejects empty string', () => { it('rejects empty string', () => {
@@ -299,4 +315,82 @@ describe('DateTime', () => {
).toThrow(); ).toThrow();
}); });
}); });
describe('startOfDay', () => {
it('returns midnight of the same calendar day in DEFAULT_TIMEZONE', () => {
delete process.env.DEFAULT_TIMEZONE;
const dt = DateTime.create('2026-08-20T17:30:00+07:00');
const start = dt.startOfDay();
expect(start.value).toBe(Date.UTC(2026, 7, 19, 17, 0, 0, 0));
expect(start.equals(DateTime.create('2026-08-20'))).toBe(true);
});
it('does not mutate the original instant', () => {
const dt = DateTime.create('2026-08-20T10:00:00Z');
const before = dt.value;
dt.startOfDay();
expect(dt.value).toBe(before);
});
});
describe('weekdayName', () => {
it('returns monday through sunday in DEFAULT_TIMEZONE', () => {
delete process.env.DEFAULT_TIMEZONE;
expect(DateTime.create('2026-08-24').weekdayName()).toBe('monday');
expect(DateTime.create('2026-08-25').weekdayName()).toBe('tuesday');
expect(DateTime.create('2026-08-26').weekdayName()).toBe('wednesday');
expect(DateTime.create('2026-08-27').weekdayName()).toBe('thursday');
expect(DateTime.create('2026-08-28').weekdayName()).toBe('friday');
expect(DateTime.create('2026-08-29').weekdayName()).toBe('saturday');
expect(DateTime.create('2026-08-30').weekdayName()).toBe('sunday');
});
it('uses the calendar day in DEFAULT_TIMEZONE, not UTC', () => {
delete process.env.DEFAULT_TIMEZONE;
// 2026-08-24 00:30 GMT+7 is still Sunday UTC
const dt = DateTime.create('2026-08-24T00:30:00+07:00');
expect(dt.weekdayName()).toBe('monday');
});
});
describe('wholeWeeksSince', () => {
it('returns 0 on the epoch day and through the next 6 days', () => {
delete process.env.DEFAULT_TIMEZONE;
const epoch = DateTime.create('2026-01-05');
expect(DateTime.create('2026-01-05').wholeWeeksSince(epoch)).toBe(0);
expect(DateTime.create('2026-01-11').wholeWeeksSince(epoch)).toBe(0);
});
it('returns 1 at +7 days (next week)', () => {
delete process.env.DEFAULT_TIMEZONE;
const epoch = DateTime.create('2026-01-05');
expect(DateTime.create('2026-01-12').wholeWeeksSince(epoch)).toBe(1);
});
it('wraps so remainder 0 of a 1-based week index is the last cycle', () => {
delete process.env.DEFAULT_TIMEZONE;
const epoch = DateTime.create('2026-01-05');
const totalCycles = 3;
const week2 = DateTime.create('2026-01-19'); // wholeWeeks = 2
const cycleNumber = (week2.wholeWeeksSince(epoch) % totalCycles) + 1;
expect(week2.wholeWeeksSince(epoch)).toBe(2);
expect(cycleNumber).toBe(3);
});
it('returns a negative count when the date is before the epoch', () => {
delete process.env.DEFAULT_TIMEZONE;
const epoch = DateTime.create('2026-01-05');
expect(DateTime.create('2025-12-29').wholeWeeksSince(epoch)).toBe(-1);
});
});
}); });
@@ -9,6 +9,24 @@ const MAX_UNIX_MS = 8_640_000_000_000_000;
const ISO_DATETIME = const ISO_DATETIME =
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(?:(Z)|([+-])(\d{2})(?::?(\d{2}))?)?$/i; /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(?:(Z)|([+-])(\d{2})(?::?(\d{2}))?)?$/i;
/** Calendar date (YYYY-MM-DD), interpreted as 00:00:00 in DEFAULT_TIMEZONE. */
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
const WEEKDAY_NAMES = [
'sunday',
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
] as const;
const MS_PER_DAY = 86_400_000;
const MS_PER_WEEK = 7 * MS_PER_DAY;
export type WeekdayName = (typeof WEEKDAY_NAMES)[number];
/** Fixed offset forms: GMT+7, UTC+07:00, +7, +07:00, +0700, Z, UTC */ /** Fixed offset forms: GMT+7, UTC+07:00, +7, +07:00, +0700, Z, UTC */
const TZ_OFFSET = const TZ_OFFSET =
/^(?:(?:GMT|UTC)\s*)?([+-])(\d{1,2})(?::?(\d{2}))?$|^(?:Z|UTC|GMT)$/i; /^(?:(?:GMT|UTC)\s*)?([+-])(\d{1,2})(?::?(\d{2}))?$|^(?:Z|UTC|GMT)$/i;
@@ -149,6 +167,20 @@ export class DateTime {
} }
const trimmed = raw.trim(); const trimmed = raw.trim();
const dateOnly = ISO_DATE.exec(trimmed);
if (dateOnly) {
return DateTime.fromParts(
Number.parseInt(dateOnly[1], 10),
Number.parseInt(dateOnly[2], 10),
Number.parseInt(dateOnly[3], 10),
0,
0,
0,
0,
resolveDefaultOffsetMinutes(),
);
}
const match = ISO_DATETIME.exec(trimmed); const match = ISO_DATETIME.exec(trimmed);
if (!match) { if (!match) {
throw new InvalidDateTimeError(); throw new InvalidDateTimeError();
@@ -175,7 +207,7 @@ export class DateTime {
offsetMinutes = resolveDefaultOffsetMinutes(); offsetMinutes = resolveDefaultOffsetMinutes();
} }
const utcMs = utcMsFromParts( return DateTime.fromParts(
year, year,
month, month,
day, day,
@@ -185,8 +217,6 @@ export class DateTime {
ms, ms,
offsetMinutes, offsetMinutes,
); );
return new DateTime(utcMs, DateTime.createToken);
} }
static fromUnixMs(ms: number): DateTime { static fromUnixMs(ms: number): DateTime {
@@ -217,6 +247,32 @@ export class DateTime {
return formatInOffset(this.unixMs, offsetMinutes); return formatInOffset(this.unixMs, offsetMinutes);
} }
startOfDay(): DateTime {
const offsetMinutes = resolveDefaultOffsetMinutes();
const shifted = new Date(this.unixMs + offsetMinutes * 60_000);
return DateTime.fromParts(
shifted.getUTCFullYear(),
shifted.getUTCMonth() + 1,
shifted.getUTCDate(),
0,
0,
0,
0,
offsetMinutes,
);
}
weekdayName(): WeekdayName {
const offsetMinutes = resolveDefaultOffsetMinutes();
const shifted = new Date(this.unixMs + offsetMinutes * 60_000);
return WEEKDAY_NAMES[shifted.getUTCDay()];
}
wholeWeeksSince(epoch: DateTime): number {
const diff = this.startOfDay().value - epoch.startOfDay().value;
return Math.trunc(diff / MS_PER_WEEK);
}
toString(): string { toString(): string {
return this.format(); return this.format();
} }
@@ -224,4 +280,27 @@ export class DateTime {
toJSON(): number { toJSON(): number {
return this.unixMs; return this.unixMs;
} }
private static fromParts(
year: number,
month: number,
day: number,
hour: number,
minute: number,
second: number,
ms: number,
offsetMinutes: number,
): DateTime {
const utcMs = utcMsFromParts(
year,
month,
day,
hour,
minute,
second,
ms,
offsetMinutes,
);
return new DateTime(utcMs, DateTime.createToken);
}
} }
+15
View File
@@ -0,0 +1,15 @@
import { bigint, pgTable, uuid } from 'drizzle-orm/pg-core';
import { primaryEntityColumns } from './primary-entity-columns';
import { users } from './schema';
/**
* Company-wide operational settings (singleton aggregate).
*/
export const companySettings = pgTable('company_settings', {
id: uuid('id').defaultRandom().notNull().primaryKey(),
cycleStartDate: bigint('cycle_start_date', { mode: 'number' }).notNull(),
...primaryEntityColumns(users),
});
export type CompanySettingsRow = typeof companySettings.$inferSelect;
export type NewCompanySettingsRow = typeof companySettings.$inferInsert;
+83
View File
@@ -0,0 +1,83 @@
import { sql } from 'drizzle-orm';
import {
index,
integer,
jsonb,
pgTable,
text,
uniqueIndex,
uuid,
} from 'drizzle-orm/pg-core';
import { branches } from './branches-table';
import { customers } from './customers-table';
import { employees } from './employees-table';
import { primaryEntityColumns } from './primary-entity-columns';
import { users } from './schema';
export type StoredRouteGeometry = {
readonly type: 'LineString';
readonly coordinates: readonly (readonly [number, number])[];
};
export const cycles = pgTable(
'cycles',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
employeeId: uuid('employee_id')
.notNull()
.references(() => employees.id, { onDelete: 'restrict' }),
purpose: text('purpose').notNull(),
cycleNumber: integer('cycle_number').notNull(),
...primaryEntityColumns(users),
},
(t) => [
uniqueIndex('cycles_employee_purpose_number_live_unique')
.on(t.employeeId, t.purpose, t.cycleNumber)
.where(sql`${t.status} <> 'archived'`),
index('cycles_employee_id_idx').on(t.employeeId),
],
);
export const cycleWeekdays = pgTable(
'cycle_weekdays',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
cycleId: uuid('cycle_id')
.notNull()
.references(() => cycles.id, { onDelete: 'cascade' }),
weekday: text('weekday').notNull(),
startBranchId: uuid('start_branch_id')
.notNull()
.references(() => branches.id, { onDelete: 'restrict' }),
endBranchId: uuid('end_branch_id')
.notNull()
.references(() => branches.id, { onDelete: 'restrict' }),
routeGeometry: jsonb('route_geometry')
.$type<StoredRouteGeometry>()
.notNull(),
},
(t) => [
uniqueIndex('cycle_weekdays_cycle_weekday_unique').on(t.cycleId, t.weekday),
index('cycle_weekdays_cycle_id_idx').on(t.cycleId),
],
);
export const cycleDestinations = pgTable(
'cycle_destinations',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
cycleWeekdayId: uuid('cycle_weekday_id')
.notNull()
.references(() => cycleWeekdays.id, { onDelete: 'cascade' }),
customerId: uuid('customer_id')
.notNull()
.references(() => customers.id, { onDelete: 'restrict' }),
sortOrder: integer('sort_order').notNull(),
},
(t) => [index('cycle_destinations_weekday_id_idx').on(t.cycleWeekdayId)],
);
export type CycleRow = typeof cycles.$inferSelect;
export type NewCycleRow = typeof cycles.$inferInsert;
export type CycleWeekdayRow = typeof cycleWeekdays.$inferSelect;
export type CycleDestinationRow = typeof cycleDestinations.$inferSelect;
+111
View File
@@ -0,0 +1,111 @@
import { sql } from 'drizzle-orm';
import {
bigint,
index,
integer,
jsonb,
pgTable,
text,
uniqueIndex,
uuid,
} from 'drizzle-orm/pg-core';
import { branches } from './branches-table';
import { customers } from './customers-table';
import { employees } from './employees-table';
import { packingSlips } from './packing-slips-table';
import { primaryEntityColumns } from './primary-entity-columns';
import { salesInvoices } from './sales-invoices-table';
import { users } from './schema';
import type { StoredRouteGeometry } from './cycles-table';
export const plans = pgTable(
'plans',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
employeeId: uuid('employee_id')
.notNull()
.references(() => employees.id, { onDelete: 'restrict' }),
purpose: text('purpose').notNull(),
date: bigint('date', { mode: 'number' }).notNull(),
startBranchId: uuid('start_branch_id')
.notNull()
.references(() => branches.id, { onDelete: 'restrict' }),
endBranchId: uuid('end_branch_id')
.notNull()
.references(() => branches.id, { onDelete: 'restrict' }),
routeGeometry: jsonb('route_geometry')
.$type<StoredRouteGeometry>()
.notNull(),
...primaryEntityColumns(users),
},
(t) => [
uniqueIndex('plans_employee_purpose_date_live_unique')
.on(t.employeeId, t.purpose, t.date)
.where(sql`${t.status} <> 'archived'`),
index('plans_employee_id_idx').on(t.employeeId),
],
);
export const planDestinations = pgTable(
'plan_destinations',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
planId: uuid('plan_id')
.notNull()
.references(() => plans.id, { onDelete: 'cascade' }),
customerId: uuid('customer_id')
.notNull()
.references(() => customers.id, { onDelete: 'restrict' }),
sortOrder: integer('sort_order').notNull(),
},
(t) => [
uniqueIndex('plan_destinations_plan_customer_unique').on(
t.planId,
t.customerId,
),
index('plan_destinations_plan_id_idx').on(t.planId),
],
);
export const planInvoices = pgTable(
'plan_invoices',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
planId: uuid('plan_id')
.notNull()
.references(() => plans.id, { onDelete: 'cascade' }),
invoiceId: uuid('invoice_id')
.notNull()
.references(() => salesInvoices.id, { onDelete: 'restrict' }),
},
(t) => [
uniqueIndex('plan_invoices_plan_invoice_unique').on(t.planId, t.invoiceId),
index('plan_invoices_plan_id_idx').on(t.planId),
],
);
export const planPackingSlips = pgTable(
'plan_packing_slips',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
planId: uuid('plan_id')
.notNull()
.references(() => plans.id, { onDelete: 'cascade' }),
packingSlipId: uuid('packing_slip_id')
.notNull()
.references(() => packingSlips.id, { onDelete: 'restrict' }),
},
(t) => [
uniqueIndex('plan_packing_slips_plan_slip_unique').on(
t.planId,
t.packingSlipId,
),
index('plan_packing_slips_plan_id_idx').on(t.planId),
],
);
export type PlanRow = typeof plans.$inferSelect;
export type NewPlanRow = typeof plans.$inferInsert;
export type PlanDestinationRow = typeof planDestinations.$inferSelect;
export type PlanInvoiceRow = typeof planInvoices.$inferSelect;
export type PlanPackingSlipRow = typeof planPackingSlips.$inferSelect;
+26
View File
@@ -210,3 +210,29 @@ export {
type SalesPaymentInvoiceRow, type SalesPaymentInvoiceRow,
type SalesPaymentRow, type SalesPaymentRow,
} from './sales-payments-table'; } from './sales-payments-table';
export {
companySettings,
type CompanySettingsRow,
type NewCompanySettingsRow,
} from './company-settings-table';
export {
cycleDestinations,
cycleWeekdays,
cycles,
type CycleDestinationRow,
type CycleRow,
type CycleWeekdayRow,
type NewCycleRow,
} from './cycles-table';
export {
planDestinations,
planInvoices,
planPackingSlips,
plans,
type NewPlanRow,
type PlanDestinationRow,
type PlanInvoiceRow,
type PlanPackingSlipRow,
type PlanRow,
} from './plans-table';
+1
View File
@@ -7,6 +7,7 @@ async function bootstrap() {
const env = loadEnv(); const env = loadEnv();
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
configureApp(app); configureApp(app);
app.enableCors();
await app.listen(env.PORT); await app.listen(env.PORT);
} }
void bootstrap(); void bootstrap();
@@ -113,6 +113,16 @@ export class BranchesService {
return this.toListItem(branch); return this.toListItem(branch);
} }
async findByCode(
code: string,
): Promise<ReturnType<BranchesService['toListItem']>> {
const branch = await this.branchesRepository.findByCode(code);
if (!branch) {
throw new NotFoundException('Branch not found');
}
return this.toListItem(branch);
}
async create(input: { async create(input: {
code: string; code: string;
name: string; name: string;
@@ -78,6 +78,20 @@ export class CustomersRepository {
return this.toDomain(row, contacts); return this.toDomain(row, contacts);
} }
async findByCode(code: string): Promise<Customer | null> {
const rows: CustomerRow[] = await this.db
.select()
.from(customers)
.where(eq(customers.code, code))
.limit(1);
const row = rows[0];
if (!row) {
return null;
}
const contacts = await this.selectContacts(this.db, row.id);
return this.toDomain(row, contacts);
}
async create(input: CreateCustomerInput): Promise<Customer> { async create(input: CreateCustomerInput): Promise<Customer> {
const now = DateTime.fromUnixMs(Date.now()); const now = DateTime.fromUnixMs(Date.now());
const status = input.status ?? Status.create(Status.DEFAULT); const status = input.status ?? Status.create(Status.DEFAULT);
@@ -104,6 +104,16 @@ export class CustomersService {
return this.toDetail(customer); return this.toDetail(customer);
} }
async findByCode(
code: string,
): Promise<ReturnType<CustomersService['toDetail']>> {
const customer = await this.customersRepository.findByCode(code);
if (!customer) {
throw new NotFoundException('Customer not found');
}
return this.toDetail(customer);
}
async create(input: { async create(input: {
code: string; code: string;
name: string; name: string;
@@ -83,6 +83,16 @@ export class EmployeesService {
return this.toListItem(employee); return this.toListItem(employee);
} }
async findByCode(
code: string,
): Promise<ReturnType<EmployeesService['toListItem']>> {
const employee = await this.employeesRepository.findByCode(code);
if (!employee) {
throw new NotFoundException('Employee not found');
}
return this.toListItem(employee);
}
async create(input: { async create(input: {
code: string; code: string;
name: string; name: string;
+69
View File
@@ -0,0 +1,69 @@
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
import type { FieldPurpose, WeekdayName } from '../shared/field-purpose';
import type { RouteGeometry } from '../shared/route-line-string';
import type { WeekdaysInput } from '../shared/field-fields';
export type CycleDestination = {
readonly id: string;
readonly customerId: string;
readonly sortOrder: number;
};
export type CycleWeekday = {
readonly id: string;
readonly weekday: WeekdayName;
readonly startBranchId: string;
readonly endBranchId: string;
readonly routeGeometry: RouteGeometry;
readonly destinations: readonly CycleDestination[];
};
export type Cycle = {
readonly id: string;
readonly employeeId: string;
readonly purpose: FieldPurpose;
readonly cycleNumber: number;
readonly weekdays: readonly CycleWeekday[];
readonly status: Status;
readonly createdAt: DateTime;
readonly updatedAt: DateTime;
readonly createdBy: string;
readonly updatedBy: string;
};
export type CreateCycleInput = {
readonly employeeId: string;
readonly purpose: FieldPurpose;
readonly cycleNumber: number;
readonly weekdays: WeekdaysInput;
readonly status?: Status;
readonly userId: string;
};
export type UpdateCycleInput = {
readonly employeeId?: string;
readonly purpose?: FieldPurpose;
readonly cycleNumber?: number;
readonly weekdays?: WeekdaysInput;
readonly userId: string;
};
export type ListCyclesFilters = {
readonly employeeId?: string;
readonly purpose?: string;
readonly cycleNumber?: number;
readonly status?: string;
readonly search?: string;
readonly purposes?: readonly string[];
readonly limit: number;
readonly offset: number;
};
export type PersistableWeekday = {
readonly weekday: WeekdayName;
readonly startBranchId: string;
readonly endBranchId: string;
readonly routeGeometry: RouteGeometry;
readonly customerIds: readonly string[];
};
@@ -0,0 +1,37 @@
import { Test, TestingModule } from '@nestjs/testing';
import { FieldPrivilegeGuard } from '../shared/field-privilege.guard';
import { CyclesReadController } from './cycles-read.controller';
import { CyclesService } from './cycles.service';
describe('CyclesReadController', () => {
let controller: CyclesReadController;
const service = { list: jest.fn(), findById: jest.fn() };
const user = { id: 'user-1', isSuperadmin: true };
beforeEach(async () => {
jest.clearAllMocks();
const moduleRef: TestingModule = await Test.createTestingModule({
controllers: [CyclesReadController],
providers: [{ provide: CyclesService, useValue: service }],
})
.overrideGuard(FieldPrivilegeGuard)
.useValue({ canActivate: () => true })
.compile();
controller = moduleRef.get(CyclesReadController);
});
it('list delegates to the service', async () => {
service.list.mockResolvedValue({ data: [], total: 0 });
await expect(controller.list({ page: 1 }, user as never)).resolves.toEqual({
data: [],
total: 0,
});
});
it('findOne delegates to the service', async () => {
service.findById.mockResolvedValue({ id: 'cyc-1' });
await expect(controller.findOne('cyc-1', user as never)).resolves.toEqual({
id: 'cyc-1',
});
});
});
@@ -0,0 +1,77 @@
import {
Controller,
Get,
Param,
ParseUUIDPipe,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiForbiddenResponse,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
import type { AuthUser } from '../../../common/auth/auth-user';
import {
Pagination,
type PaginationResponse,
PaginationMetaDto,
} from '../../../common/http/response';
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
import { RequireFieldPrivilege } from '../shared/field-privilege.decorator';
import { FieldPrivilegeGuard } from '../shared/field-privilege.guard';
import { CyclesService } from './cycles.service';
import { CycleDto, ListCyclesQueryDto } from './dto/cycle.dto';
@ApiTags('cycles')
@ApiBearerAuth(BEARER_AUTH_NAME)
@UseGuards(FieldPrivilegeGuard)
@Controller('cycles')
export class CyclesReadController {
constructor(private readonly cyclesService: CyclesService) {}
@Get()
@Pagination()
@RequireFieldPrivilege('cycle', 'view')
@ApiOperation({ summary: 'List cycles' })
@ApiOkResponse({
schema: {
properties: {
data: {
type: 'array',
items: { $ref: '#/components/schemas/CycleDto' },
},
meta: { $ref: '#/components/schemas/PaginationMetaDto' },
},
},
})
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
list(
@Query() query: ListCyclesQueryDto,
@CurrentUser() user: AuthUser,
): Promise<PaginationResponse<CycleDto>> {
return this.cyclesService.list(query, user);
}
@Get(':id')
@RequireFieldPrivilege('cycle', 'view')
@ApiOperation({ summary: 'Get cycle detail' })
@ApiOkResponse({ type: CycleDto })
@ApiNotFoundResponse()
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
findOne(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUser,
): Promise<CycleDto> {
return this.cyclesService.findById(id, user);
}
}
void PaginationMetaDto;
@@ -0,0 +1,62 @@
import { Test, TestingModule } from '@nestjs/testing';
import { FieldPrivilegeGuard } from '../shared/field-privilege.guard';
import { CyclesWriteController } from './cycles-write.controller';
import { CyclesService } from './cycles.service';
describe('CyclesWriteController', () => {
let controller: CyclesWriteController;
const service = {
create: jest.fn(),
update: jest.fn(),
updateStatus: jest.fn(),
delete: jest.fn(),
bulkDelete: jest.fn(),
bulkUpdateStatus: jest.fn(),
importCsv: jest.fn(),
};
const user = {
id: 'user-1',
username: 'alice',
jti: 'j',
isSuperadmin: true,
};
beforeEach(async () => {
jest.clearAllMocks();
const moduleRef: TestingModule = await Test.createTestingModule({
controllers: [CyclesWriteController],
providers: [{ provide: CyclesService, useValue: service }],
})
.overrideGuard(FieldPrivilegeGuard)
.useValue({ canActivate: () => true })
.compile();
controller = moduleRef.get(CyclesWriteController);
});
it('create passes dto fields and user id', async () => {
service.create.mockResolvedValue({ id: 'cyc-1' });
await controller.create(
{
employeeId: 'emp-1',
purpose: 'sales',
cycleNumber: 1,
weekdays: {},
},
'user-1',
);
expect(service.create).toHaveBeenCalledWith({
employeeId: 'emp-1',
purpose: 'sales',
cycleNumber: 1,
weekdays: {},
status: undefined,
userId: 'user-1',
});
});
it('delete archives through the service', async () => {
service.delete.mockResolvedValue(undefined);
await controller.delete('cyc-1', user as never);
expect(service.delete).toHaveBeenCalledWith('cyc-1', user);
});
});
@@ -0,0 +1,193 @@
import {
BadRequestException,
Body,
Controller,
Delete,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import {
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiCreatedResponse,
ApiForbiddenResponse,
ApiNoContentResponse,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import type { AuthUser } from '../../../common/auth/auth-user';
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
import { isAllowedCsvUpload } from '../shared/field-fields';
import { RequireFieldPrivilege } from '../shared/field-privilege.decorator';
import { FieldPrivilegeGuard } from '../shared/field-privilege.guard';
import { CyclesService } from './cycles.service';
import {
BulkIdsDto,
BulkStatusDto,
CreateCycleDto,
CycleDto,
UpdateCycleDto,
UpdateCycleStatusDto,
} from './dto/cycle.dto';
@ApiTags('cycles')
@ApiBearerAuth(BEARER_AUTH_NAME)
@UseGuards(FieldPrivilegeGuard)
@Controller('cycles')
export class CyclesWriteController {
constructor(private readonly cyclesService: CyclesService) {}
@Post('import')
@RequireFieldPrivilege('cycle', 'import')
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: 1_048_576 },
fileFilter: (_req, file, cb) => {
if (!isAllowedCsvUpload(file)) {
cb(new BadRequestException('Only CSV files are allowed'), false);
return;
}
cb(null, true);
},
}),
)
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: { file: { type: 'string', format: 'binary' } },
required: ['file'],
},
})
@ApiOperation({ summary: 'Import cycles from CSV' })
@ApiOkResponse({ schema: { properties: { imported: { type: 'number' } } } })
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
importCsv(
@UploadedFile() file: { buffer?: Buffer } | undefined,
@CurrentUser('id') userId: string,
): Promise<{ imported: number }> {
const csv = file?.buffer?.toString('utf8') ?? '';
return this.cyclesService.importCsv(csv, userId);
}
@Post('bulk-delete')
@HttpCode(200)
@RequireFieldPrivilege('cycle', 'delete')
@ApiOperation({ summary: 'Bulk archive cycles' })
@ApiOkResponse({ schema: { properties: { deleted: { type: 'number' } } } })
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
bulkDelete(
@Body() dto: BulkIdsDto,
@CurrentUser() user: AuthUser,
): Promise<{ deleted: number }> {
return this.cyclesService.bulkDelete(dto.ids, user);
}
@Post('bulk-status')
@HttpCode(200)
@RequireFieldPrivilege('cycle', 'update')
@ApiOperation({ summary: 'Bulk update cycle status' })
@ApiOkResponse({ schema: { properties: { updated: { type: 'number' } } } })
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
bulkStatus(
@Body() dto: BulkStatusDto,
@CurrentUser() user: AuthUser,
): Promise<{ updated: number }> {
return this.cyclesService.bulkUpdateStatus(
dto.ids,
dto.status,
user.id,
user,
);
}
@Post()
@RequireFieldPrivilege('cycle', 'create')
@ApiOperation({ summary: 'Create cycle' })
@ApiCreatedResponse({ type: CycleDto })
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
create(
@Body() dto: CreateCycleDto,
@CurrentUser('id') userId: string,
): Promise<CycleDto> {
return this.cyclesService.create({
employeeId: dto.employeeId,
purpose: dto.purpose,
cycleNumber: dto.cycleNumber,
weekdays: dto.weekdays,
status: dto.status,
userId,
});
}
@Patch(':id/status')
@RequireFieldPrivilege('cycle', 'update')
@ApiOperation({ summary: 'Update cycle status' })
@ApiOkResponse({ type: CycleDto })
@ApiNotFoundResponse()
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
updateStatus(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateCycleStatusDto,
@CurrentUser() user: AuthUser,
): Promise<CycleDto> {
return this.cyclesService.updateStatus(id, dto.status, user.id, user);
}
@Patch(':id')
@RequireFieldPrivilege('cycle', 'update')
@ApiOperation({ summary: 'Update cycle (not status)' })
@ApiOkResponse({ type: CycleDto })
@ApiNotFoundResponse()
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateCycleDto,
@CurrentUser() user: AuthUser,
): Promise<CycleDto> {
return this.cyclesService.update(
id,
{
employeeId: dto.employeeId,
purpose: dto.purpose,
cycleNumber: dto.cycleNumber,
weekdays: dto.weekdays,
userId: user.id,
},
user,
);
}
@Delete(':id')
@HttpCode(204)
@RequireFieldPrivilege('cycle', 'delete')
@ApiOperation({ summary: 'Archive cycle' })
@ApiNoContentResponse()
@ApiNotFoundResponse()
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
async delete(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUser,
): Promise<void> {
await this.cyclesService.delete(id, user);
}
}
@@ -0,0 +1,77 @@
import { ConflictException, NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { Status } from '../../../common/value-objects/status/status';
import { DRIZZLE } from '../../../database/database.module';
import { CyclesRepository } from './cycles.repository';
describe('CyclesRepository', () => {
let repository: CyclesRepository;
const limit = jest.fn();
const where = jest.fn();
const from = jest.fn();
const select = jest.fn();
const returning = jest.fn();
const values = jest.fn();
const insert = jest.fn();
const set = jest.fn();
const update = jest.fn();
const transaction = jest.fn();
const orderBy = jest.fn();
const offset = jest.fn();
const $dynamic = jest.fn();
const db = {
select,
insert,
update,
transaction,
};
beforeEach(async () => {
jest.clearAllMocks();
where.mockImplementation(() => ({ limit, orderBy, returning }));
orderBy.mockImplementation(() => ({ limit }));
limit.mockImplementation(() => ({ offset }));
offset.mockResolvedValue([]);
from.mockImplementation(() => ({ where, $dynamic }));
$dynamic.mockReturnValue({ where });
select.mockImplementation(() => ({ from }));
values.mockReturnValue({ returning });
insert.mockReturnValue({ values });
set.mockReturnValue({ where });
update.mockReturnValue({ set });
returning.mockResolvedValue([]);
transaction.mockImplementation((fn: (tx: typeof db) => unknown) => fn(db));
const moduleRef: TestingModule = await Test.createTestingModule({
providers: [CyclesRepository, { provide: DRIZZLE, useValue: db }],
}).compile();
repository = moduleRef.get(CyclesRepository);
});
it('extendListQuery is a passthrough hook', () => {
const qb = { tag: 'qb' };
expect(repository.extendListQuery(qb, { limit: 10, offset: 0 })).toBe(qb);
});
it('create maps unique violations to ConflictException', async () => {
transaction.mockRejectedValue({ code: '23505' });
await expect(
repository.create({
employeeId: 'emp-1',
purpose: 'sales',
cycleNumber: 1,
weekdays: [],
status: Status.create('draft'),
userId: 'user-1',
}),
).rejects.toBeInstanceOf(ConflictException);
});
it('archive throws when no row is updated', async () => {
returning.mockResolvedValue([]);
await expect(
repository.archive('missing', 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
});
});
@@ -0,0 +1,451 @@
import {
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
and,
asc,
count,
eq,
ilike,
inArray,
ne,
or,
sql,
SQL,
} from 'drizzle-orm';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
import {
cycleDestinations,
cycleWeekdays,
cycles,
type CycleDestinationRow,
type CycleRow,
type CycleWeekdayRow,
} from '../../../database/cycles-table';
import type { FieldPurpose, WeekdayName } from '../shared/field-purpose';
import type { Cycle, ListCyclesFilters, PersistableWeekday } from './cycle';
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete' | 'update'>;
@Injectable()
export class CyclesRepository {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
async list(
filters: ListCyclesFilters,
): Promise<{ data: Cycle[]; total: number }> {
const where = this.buildListWhere(filters);
const totalRows = await this.db
.select({ total: count() })
.from(cycles)
.where(where);
let qb = this.db.select().from(cycles).$dynamic();
qb = this.extendListQuery(qb, filters);
const rows = await qb
.where(where)
.orderBy(asc(cycles.cycleNumber))
.limit(filters.limit)
.offset(filters.offset);
return {
data: rows.map((row) => this.toDomain(row, [], [])),
total: Number(totalRows[0]?.total ?? 0),
};
}
extendListQuery<T>(qb: T, filters: ListCyclesFilters): T {
void filters;
return qb;
}
async findById(id: string): Promise<Cycle | null> {
const rows: CycleRow[] = await this.db
.select()
.from(cycles)
.where(eq(cycles.id, id))
.limit(1);
const row = rows[0];
if (!row) {
return null;
}
const weekdays = await this.selectWeekdays(this.db, id);
const destinations = await this.selectDestinations(
this.db,
weekdays.map((weekday) => weekday.id),
);
return this.toDomain(row, weekdays, destinations);
}
async findLiveByKey(
employeeId: string,
purpose: string,
cycleNumber: number,
excludeId?: string,
): Promise<Cycle | null> {
const parts: SQL[] = [
eq(cycles.employeeId, employeeId),
eq(cycles.purpose, purpose),
eq(cycles.cycleNumber, cycleNumber),
ne(cycles.status, 'archived'),
];
if (excludeId) {
parts.push(ne(cycles.id, excludeId));
}
const rows = await this.db
.select()
.from(cycles)
.where(and(...parts))
.limit(1);
const row = rows[0];
return row ? this.toDomain(row, [], []) : null;
}
async listActiveByEmployeePurpose(
employeeId: string,
purpose: string,
): Promise<Cycle[]> {
const rows = await this.db
.select()
.from(cycles)
.where(
and(
eq(cycles.employeeId, employeeId),
eq(cycles.purpose, purpose),
eq(cycles.status, 'active'),
),
)
.orderBy(asc(cycles.cycleNumber));
const result: Cycle[] = [];
for (const row of rows) {
const weekdays = await this.selectWeekdays(this.db, row.id);
const destinations = await this.selectDestinations(
this.db,
weekdays.map((weekday) => weekday.id),
);
result.push(this.toDomain(row, weekdays, destinations));
}
return result;
}
async create(input: {
employeeId: string;
purpose: FieldPurpose;
cycleNumber: number;
weekdays: readonly PersistableWeekday[];
status: Status;
userId: string;
}): Promise<Cycle> {
const now = DateTime.fromUnixMs(Date.now());
try {
return await this.db.transaction(async (tx) => {
const inserted = await tx
.insert(cycles)
.values({
employeeId: input.employeeId,
purpose: input.purpose,
cycleNumber: input.cycleNumber,
status: input.status.value,
createdAt: now.value,
updatedAt: now.value,
createdBy: input.userId,
updatedBy: input.userId,
})
.returning();
const row = inserted[0];
await this.replaceWeekdays(tx, row.id, input.weekdays);
return this.loadWithChildren(tx, row);
});
} catch (error) {
this.rethrowConstraintViolation(error);
}
}
async update(
id: string,
input: {
employeeId: string;
purpose: FieldPurpose;
cycleNumber: number;
weekdays?: readonly PersistableWeekday[];
userId: string;
},
): Promise<Cycle> {
const existing = await this.findById(id);
if (!existing) {
throw new NotFoundException('Cycle not found');
}
const now = DateTime.fromUnixMs(Date.now());
try {
return await this.db.transaction(async (tx) => {
const updated = await tx
.update(cycles)
.set({
employeeId: input.employeeId,
purpose: input.purpose,
cycleNumber: input.cycleNumber,
updatedAt: now.value,
updatedBy: input.userId,
})
.where(eq(cycles.id, id))
.returning();
const row = updated[0];
if (input.weekdays) {
await this.replaceWeekdays(tx, id, input.weekdays);
}
return this.loadWithChildren(tx, row);
});
} catch (error) {
this.rethrowConstraintViolation(error);
}
}
async updateStatus(
id: string,
status: Status,
userId: string,
): Promise<Cycle> {
const now = DateTime.fromUnixMs(Date.now());
const updated = await this.db
.update(cycles)
.set({
status: status.value,
updatedAt: now.value,
updatedBy: userId,
})
.where(eq(cycles.id, id))
.returning({ id: cycles.id });
if (updated.length === 0) {
throw new NotFoundException('Cycle not found');
}
const found = await this.findById(id);
if (!found) {
throw new NotFoundException('Cycle not found');
}
return found;
}
async bulkUpdateStatus(
ids: string[],
status: Status,
userId: string,
): Promise<number> {
if (ids.length === 0) {
return 0;
}
const now = DateTime.fromUnixMs(Date.now());
const rows = await this.db
.update(cycles)
.set({
status: status.value,
updatedAt: now.value,
updatedBy: userId,
})
.where(inArray(cycles.id, ids))
.returning({ id: cycles.id });
return rows.length;
}
async archive(id: string, userId: string): Promise<void> {
const now = DateTime.fromUnixMs(Date.now());
const updated = await this.db
.update(cycles)
.set({
status: 'archived',
updatedAt: now.value,
updatedBy: userId,
})
.where(eq(cycles.id, id))
.returning({ id: cycles.id });
if (updated.length === 0) {
throw new NotFoundException('Cycle not found');
}
}
async bulkArchive(ids: string[], userId: string): Promise<number> {
if (ids.length === 0) {
return 0;
}
const now = DateTime.fromUnixMs(Date.now());
const rows = await this.db
.update(cycles)
.set({
status: 'archived',
updatedAt: now.value,
updatedBy: userId,
})
.where(inArray(cycles.id, ids))
.returning({ id: cycles.id });
return rows.length;
}
private async loadWithChildren(
executor: QueryExecutor,
row: CycleRow,
): Promise<Cycle> {
const weekdays = await this.selectWeekdays(executor, row.id);
const destinations = await this.selectDestinations(
executor,
weekdays.map((weekday) => weekday.id),
);
return this.toDomain(row, weekdays, destinations);
}
private async replaceWeekdays(
executor: QueryExecutor,
cycleId: string,
weekdays: readonly PersistableWeekday[],
): Promise<void> {
await executor
.delete(cycleWeekdays)
.where(eq(cycleWeekdays.cycleId, cycleId));
for (const weekday of weekdays) {
const inserted = await executor
.insert(cycleWeekdays)
.values({
cycleId,
weekday: weekday.weekday,
startBranchId: weekday.startBranchId,
endBranchId: weekday.endBranchId,
routeGeometry: weekday.routeGeometry,
})
.returning();
const weekdayRow = inserted[0];
if (weekday.customerIds.length === 0) {
continue;
}
await executor.insert(cycleDestinations).values(
weekday.customerIds.map((customerId, index) => ({
cycleWeekdayId: weekdayRow.id,
customerId,
sortOrder: index,
})),
);
}
}
private async selectWeekdays(
executor: QueryExecutor,
cycleId: string,
): Promise<CycleWeekdayRow[]> {
return executor
.select()
.from(cycleWeekdays)
.where(eq(cycleWeekdays.cycleId, cycleId))
.orderBy(asc(cycleWeekdays.weekday));
}
private async selectDestinations(
executor: QueryExecutor,
weekdayIds: string[],
): Promise<CycleDestinationRow[]> {
if (weekdayIds.length === 0) {
return [];
}
return executor
.select()
.from(cycleDestinations)
.where(inArray(cycleDestinations.cycleWeekdayId, weekdayIds))
.orderBy(asc(cycleDestinations.sortOrder));
}
private buildListWhere(filters: ListCyclesFilters): SQL | undefined {
const parts: SQL[] = [];
if (filters.employeeId) {
parts.push(eq(cycles.employeeId, filters.employeeId));
}
if (filters.purpose) {
parts.push(eq(cycles.purpose, filters.purpose));
} else if (filters.purposes && filters.purposes.length > 0) {
parts.push(inArray(cycles.purpose, [...filters.purposes]));
}
if (filters.cycleNumber !== undefined) {
parts.push(eq(cycles.cycleNumber, filters.cycleNumber));
}
if (filters.status) {
parts.push(eq(cycles.status, filters.status));
}
if (filters.search) {
const search = or(
ilike(cycles.purpose, `%${filters.search}%`),
sql`${cycles.cycleNumber}::text ilike ${'%' + filters.search + '%'}`,
);
if (search) {
parts.push(search);
}
}
if (parts.length === 0) {
return undefined;
}
return parts.length === 1 ? parts[0] : and(...parts);
}
private toDomain(
row: CycleRow,
weekdayRows: CycleWeekdayRow[],
destinationRows: CycleDestinationRow[],
): Cycle {
const destinationsByWeekday = new Map<string, CycleDestinationRow[]>();
for (const destination of destinationRows) {
const list = destinationsByWeekday.get(destination.cycleWeekdayId) ?? [];
destinationsByWeekday.set(destination.cycleWeekdayId, [
...list,
destination,
]);
}
return {
id: row.id,
employeeId: row.employeeId,
purpose: row.purpose as FieldPurpose,
cycleNumber: row.cycleNumber,
weekdays: weekdayRows.map((weekday) => ({
id: weekday.id,
weekday: weekday.weekday as WeekdayName,
startBranchId: weekday.startBranchId,
endBranchId: weekday.endBranchId,
routeGeometry: weekday.routeGeometry,
destinations: (destinationsByWeekday.get(weekday.id) ?? []).map(
(destination) => ({
id: destination.id,
customerId: destination.customerId,
sortOrder: destination.sortOrder,
}),
),
})),
status: Status.create(row.status),
createdAt: DateTime.fromUnixMs(row.createdAt),
updatedAt: DateTime.fromUnixMs(row.updatedAt),
createdBy: row.createdBy,
updatedBy: row.updatedBy,
};
}
private rethrowConstraintViolation(error: unknown): never {
const err = this.unwrapDbError(error);
if (err.code === '23505') {
throw new ConflictException('Cycle already exists for this employee');
}
if (err.code === '23503') {
throw new ConflictException('Cycle references a missing record');
}
throw error;
}
private unwrapDbError(error: unknown): { code?: string } {
let current: unknown = error;
for (let i = 0; i < 5; i++) {
if (!current || typeof current !== 'object') {
break;
}
const obj = current as { code?: string; cause?: unknown };
if (obj.code === '23505' || obj.code === '23503') {
return { code: obj.code };
}
current = obj.cause;
}
return error as { code?: string };
}
}
@@ -0,0 +1,180 @@
import {
BadRequestException,
ConflictException,
NotFoundException,
} from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import type { AuthUser } from '../../../common/auth/auth-user';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
import { BranchesService } from '../../configuration/branches/branches.service';
import { CustomersService } from '../../configuration/customers/customers.service';
import { EmployeesService } from '../../configuration/employees/employees.service';
import { PrivilegesService } from '../../privileges/privileges.service';
import type { Cycle } from './cycle';
import { CyclesRepository } from './cycles.repository';
import { CyclesService } from './cycles.service';
describe('CyclesService', () => {
let service: CyclesService;
let repository: jest.Mocked<
Pick<
CyclesRepository,
| 'list'
| 'findById'
| 'findLiveByKey'
| 'create'
| 'update'
| 'updateStatus'
| 'bulkUpdateStatus'
| 'archive'
| 'bulkArchive'
>
>;
const employeesService = { findById: jest.fn(), findByCode: jest.fn() };
const branchesService = { findById: jest.fn(), findByCode: jest.fn() };
const customersService = { findById: jest.fn(), findByCode: jest.fn() };
const privilegesService = { checkPermission: jest.fn() };
const now = DateTime.fromUnixMs(1_700_000_000_000);
const user: AuthUser = {
id: 'user-1',
username: 'alice',
jti: 'jti-1',
isSuperadmin: true,
};
const geometry = {
type: 'LineString' as const,
coordinates: [
[106.8, -6.2],
[106.9, -6.3],
[107.0, -6.4],
] as const,
};
const sample: Cycle = {
id: 'cyc-1',
employeeId: 'emp-1',
purpose: 'sales',
cycleNumber: 1,
weekdays: [
{
id: 'wd-1',
weekday: 'monday',
startBranchId: 'br-1',
endBranchId: 'br-2',
routeGeometry: geometry,
destinations: [{ id: 'dest-1', customerId: 'cus-1', sortOrder: 0 }],
},
],
status: Status.create('draft'),
createdAt: now,
updatedAt: now,
createdBy: 'user-1',
updatedBy: 'user-1',
};
const located = { id: 'x', latitude: -6.2, longitude: 106.8 };
beforeEach(async () => {
jest.clearAllMocks();
repository = {
list: jest.fn(),
findById: jest.fn(),
findLiveByKey: jest.fn(),
create: jest.fn(),
update: jest.fn(),
updateStatus: jest.fn(),
bulkUpdateStatus: jest.fn(),
archive: jest.fn(),
bulkArchive: jest.fn(),
};
employeesService.findById.mockResolvedValue({ id: 'emp-1' });
branchesService.findById.mockResolvedValue(located);
customersService.findById.mockResolvedValue(located);
repository.findLiveByKey.mockResolvedValue(null);
repository.create.mockResolvedValue(sample);
const moduleRef: TestingModule = await Test.createTestingModule({
providers: [
CyclesService,
{ provide: CyclesRepository, useValue: repository },
{ provide: EmployeesService, useValue: employeesService },
{ provide: BranchesService, useValue: branchesService },
{ provide: CustomersService, useValue: customersService },
{ provide: PrivilegesService, useValue: privilegesService },
],
}).compile();
service = moduleRef.get(CyclesService);
});
it('create rejects incomplete weekdays', async () => {
await expect(
service.create({
employeeId: 'emp-1',
purpose: 'sales',
cycleNumber: 1,
weekdays: {
monday: {
startBranchId: 'br-1',
endBranchId: 'br-2',
customerIds: [],
},
},
userId: 'user-1',
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('create rejects duplicate live keys', async () => {
repository.findLiveByKey.mockResolvedValue(sample);
await expect(
service.create({
employeeId: 'emp-1',
purpose: 'sales',
cycleNumber: 1,
weekdays: {
monday: {
startBranchId: 'br-1',
endBranchId: 'br-2',
customerIds: ['cus-1'],
},
},
userId: 'user-1',
}),
).rejects.toBeInstanceOf(ConflictException);
});
it('create persists a complete weekday and builds geometry', async () => {
await service.create({
employeeId: 'emp-1',
purpose: 'sales',
cycleNumber: 1,
weekdays: {
monday: {
startBranchId: 'br-1',
endBranchId: 'br-2',
customerIds: ['cus-1'],
},
},
userId: 'user-1',
});
expect(repository.create).toHaveBeenCalled();
const arg = repository.create.mock.calls[0][0];
expect(arg.weekdays[0].weekday).toBe('monday');
expect(arg.weekdays[0].routeGeometry.type).toBe('LineString');
});
it('delete archives instead of hard-deleting', async () => {
repository.findById.mockResolvedValue(sample);
repository.archive.mockResolvedValue(undefined);
await service.delete('cyc-1', user);
expect(repository.archive).toHaveBeenCalledWith('cyc-1', 'user-1');
});
it('findById throws when missing', async () => {
repository.findById.mockResolvedValue(null);
await expect(service.findById('missing', user)).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
+537
View File
@@ -0,0 +1,537 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { AuthUser } from '../../../common/auth/auth-user';
import type { PaginationResponse } from '../../../common/http/response';
import { toListPage } from '../../../common/http/response';
import { InvalidStatusError } from '../../../common/value-objects/status/invalid-status.error';
import { Status } from '../../../common/value-objects/status/status';
import { BranchesService } from '../../configuration/branches/branches.service';
import { CustomersService } from '../../configuration/customers/customers.service';
import { EmployeesService } from '../../configuration/employees/employees.service';
import { PrivilegesService } from '../../privileges/privileges.service';
import {
isValidCycleNumber,
parseCsvRecord,
parseWeekdaysInput,
type WeekdaysInput,
} from '../shared/field-fields';
import {
fieldPrivilegeKey,
isFieldPurpose,
WEEKDAY_NAMES,
type FieldPurpose,
type WeekdayName,
} from '../shared/field-purpose';
import {
buildRouteLineString,
isUsableRouteGeometry,
} from '../shared/route-line-string';
import type { Cycle, PersistableWeekday } from './cycle';
import { CyclesRepository } from './cycles.repository';
export type ListCyclesQuery = {
readonly employeeId?: string;
readonly purpose?: string;
readonly cycleNumber?: number;
readonly status?: string;
readonly search?: string;
readonly page?: number;
readonly limit?: number;
readonly offset?: number;
};
const VISIBLE_FIELDS = [
'id',
'employeeId',
'purpose',
'cycleNumber',
'weekdays',
'status',
'createdAt',
'updatedAt',
'createdBy',
'updatedBy',
] as const;
const CSV_REQUIRED_HEADERS = [
'employeeCode',
'purpose',
'cycleNumber',
'weekday',
'customerCodes',
'startBranchCode',
'endBranchCode',
] as const;
@Injectable()
export class CyclesService {
constructor(
private readonly cyclesRepository: CyclesRepository,
private readonly employeesService: EmployeesService,
private readonly branchesService: BranchesService,
private readonly customersService: CustomersService,
private readonly privilegesService: PrivilegesService,
) {}
async list(
query: ListCyclesQuery,
user: AuthUser,
): Promise<PaginationResponse<ReturnType<CyclesService['toItem']>>> {
const purposes = await this.allowedPurposes(user, 'view');
if (query.purpose && !purposes.includes(query.purpose as FieldPurpose)) {
throw new ForbiddenException('Insufficient privilege');
}
const page = toListPage(query);
const { data, total } = await this.cyclesRepository.list({
employeeId: query.employeeId,
purpose: query.purpose,
cycleNumber: query.cycleNumber,
status: query.status,
search: query.search,
purposes: query.purpose ? undefined : purposes,
limit: page.limit,
offset: page.offset,
});
return { data: data.map((item) => this.toItem(item)), total };
}
async findById(
id: string,
user: AuthUser,
): Promise<ReturnType<CyclesService['toItem']>> {
const cycle = await this.requireCycle(id);
await this.assertCanAccess(user, cycle.purpose, 'view');
return this.toItem(cycle);
}
async create(input: {
employeeId: string;
purpose: string;
cycleNumber: number;
weekdays: unknown;
status?: string;
userId: string;
}): Promise<ReturnType<CyclesService['toItem']>> {
const created = await this.cyclesRepository.create(
await this.toCreatePayload(input),
);
return this.toItem(created);
}
async update(
id: string,
input: {
employeeId?: string;
purpose?: string;
cycleNumber?: number;
weekdays?: unknown;
status?: unknown;
userId: string;
},
user: AuthUser,
): Promise<ReturnType<CyclesService['toItem']>> {
if (input.status !== undefined) {
throw new BadRequestException('status cannot be updated via PATCH');
}
const existing = await this.requireCycle(id);
await this.assertCanAccess(user, existing.purpose, 'update');
const purpose = input.purpose
? this.assertPurpose(input.purpose)
: existing.purpose;
const employeeId = input.employeeId ?? existing.employeeId;
const cycleNumber = input.cycleNumber ?? existing.cycleNumber;
await this.employeesService.findById(employeeId);
await this.assertUnique(employeeId, purpose, cycleNumber, id);
let weekdays: PersistableWeekday[] | undefined;
if (input.weekdays !== undefined) {
try {
weekdays = await this.toPersistableWeekdays(
parseWeekdaysInput(input.weekdays),
);
} catch {
throw new BadRequestException('Weekday must be complete');
}
}
const updated = await this.cyclesRepository.update(id, {
employeeId,
purpose,
cycleNumber,
weekdays,
userId: input.userId,
});
return this.toItem(updated);
}
async updateStatus(
id: string,
statusRaw: string,
userId: string,
user: AuthUser,
): Promise<ReturnType<CyclesService['toItem']>> {
const existing = await this.requireCycle(id);
await this.assertCanAccess(user, existing.purpose, 'update');
const updated = await this.cyclesRepository.updateStatus(
id,
this.assertStatus(statusRaw),
userId,
);
return this.toItem(updated);
}
async bulkUpdateStatus(
ids: string[],
statusRaw: string,
userId: string,
user: AuthUser,
): Promise<{ updated: number }> {
const status = this.assertStatus(statusRaw);
await this.assertIdsAccessible(ids, user, 'update');
const updated = await this.cyclesRepository.bulkUpdateStatus(
ids,
status,
userId,
);
return { updated };
}
async delete(id: string, user: AuthUser): Promise<void> {
const existing = await this.requireCycle(id);
await this.assertCanAccess(user, existing.purpose, 'delete');
await this.cyclesRepository.archive(id, user.id);
}
async bulkDelete(
ids: string[],
user: AuthUser,
): Promise<{ deleted: number }> {
await this.assertIdsAccessible(ids, user, 'delete');
const deleted = await this.cyclesRepository.bulkArchive(ids, user.id);
return { deleted };
}
async importCsv(csv: string, userId: string): Promise<{ imported: number }> {
const groups = await this.parseImportGroups(csv);
for (const group of groups) {
await this.cyclesRepository.create(
await this.toCreatePayload({
employeeId: group.employeeId,
purpose: group.purpose,
cycleNumber: group.cycleNumber,
weekdays: group.weekdays,
userId,
}),
);
}
return { imported: groups.length };
}
get visibleFields(): readonly string[] {
return VISIBLE_FIELDS;
}
toItem(cycle: Cycle) {
return {
id: cycle.id,
employeeId: cycle.employeeId,
purpose: cycle.purpose,
cycleNumber: cycle.cycleNumber,
weekdays: cycle.weekdays.map((weekday) => ({
id: weekday.id,
weekday: weekday.weekday,
startBranchId: weekday.startBranchId,
endBranchId: weekday.endBranchId,
routeGeometry: weekday.routeGeometry,
destinations: weekday.destinations.map((destination) => ({
id: destination.id,
customerId: destination.customerId,
sortOrder: destination.sortOrder,
})),
})),
status: cycle.status.value,
createdAt: cycle.createdAt.value,
updatedAt: cycle.updatedAt.value,
createdBy: cycle.createdBy,
updatedBy: cycle.updatedBy,
};
}
private async toCreatePayload(input: {
employeeId: string;
purpose: string;
cycleNumber: number;
weekdays: unknown;
status?: string;
userId: string;
}) {
if (!isValidCycleNumber(input.cycleNumber)) {
throw new BadRequestException('Invalid cycle number');
}
const purpose = this.assertPurpose(input.purpose);
await this.employeesService.findById(input.employeeId);
await this.assertUnique(input.employeeId, purpose, input.cycleNumber);
let weekdaysInput: WeekdaysInput;
try {
weekdaysInput = parseWeekdaysInput(input.weekdays);
} catch {
throw new BadRequestException('Weekday must be complete');
}
return {
employeeId: input.employeeId,
purpose,
cycleNumber: input.cycleNumber,
weekdays: await this.toPersistableWeekdays(weekdaysInput),
status: input.status
? this.assertStatus(input.status)
: Status.create(Status.DEFAULT),
userId: input.userId,
};
}
private async toPersistableWeekdays(
weekdays: WeekdaysInput,
): Promise<PersistableWeekday[]> {
const result: PersistableWeekday[] = [];
for (const weekday of WEEKDAY_NAMES) {
const template = weekdays[weekday];
if (!template) {
continue;
}
const start = await this.branchesService.findById(template.startBranchId);
const end = await this.branchesService.findById(template.endBranchId);
const customers: Array<{
longitude: number | null;
latitude: number | null;
}> = [];
for (const customerId of template.customerIds) {
customers.push(await this.customersService.findById(customerId));
}
const geometry = buildRouteLineString([
{ longitude: start.longitude, latitude: start.latitude },
...customers.map((customer) => ({
longitude: customer.longitude,
latitude: customer.latitude,
})),
{ longitude: end.longitude, latitude: end.latitude },
]);
if (!isUsableRouteGeometry(geometry)) {
throw new BadRequestException('Weekday route is incomplete');
}
result.push({
weekday,
startBranchId: template.startBranchId,
endBranchId: template.endBranchId,
routeGeometry: geometry,
customerIds: template.customerIds,
});
}
return result;
}
private async parseImportGroups(csv: string): Promise<
Array<{
employeeId: string;
purpose: FieldPurpose;
cycleNumber: number;
weekdays: WeekdaysInput;
}>
> {
const rawLines = csv.split(/\r?\n/);
const filled = rawLines
.map((line, index) => ({ line: line.trim(), lineNo: index + 1 }))
.filter((entry) => entry.line.length > 0);
if (filled.length === 0) {
throw new BadRequestException('CSV is empty');
}
const header = parseCsvRecord(filled[0].line).map((h) =>
h.trim().toLowerCase(),
);
const missing = CSV_REQUIRED_HEADERS.filter(
(h) => header.indexOf(h.toLowerCase()) < 0,
);
if (missing.length > 0) {
throw new BadRequestException('CSV must include required headers');
}
const idx = (key: string) => header.indexOf(key.toLowerCase());
const grouped = new Map<
string,
{
employeeId: string;
purpose: FieldPurpose;
cycleNumber: number;
weekdays: WeekdaysInput;
}
>();
const errors: string[] = [];
for (let i = 1; i < filled.length; i++) {
const cols = parseCsvRecord(filled[i].line);
const rowNum = filled[i].lineNo;
try {
const employee = await this.employeesService.findByCode(
cols[idx('employeeCode')] ?? '',
);
const purpose = this.assertPurpose(cols[idx('purpose')] ?? '');
const cycleNumber = Number.parseInt(cols[idx('cycleNumber')] ?? '', 10);
if (!isValidCycleNumber(cycleNumber)) {
throw new BadRequestException('Invalid cycle number');
}
const weekdayRaw = (cols[idx('weekday')] ?? '').toLowerCase();
if (!(WEEKDAY_NAMES as readonly string[]).includes(weekdayRaw)) {
throw new BadRequestException('Weekday must be complete');
}
const weekday = weekdayRaw as WeekdayName;
const start = await this.branchesService.findByCode(
cols[idx('startBranchCode')] ?? '',
);
const end = await this.branchesService.findByCode(
cols[idx('endBranchCode')] ?? '',
);
const customerCodes = (cols[idx('customerCodes')] ?? '')
.split('|')
.map((code) => code.trim())
.filter((code) => code.length > 0);
if (customerCodes.length === 0) {
throw new BadRequestException('Weekday must be complete');
}
const customerIds: string[] = [];
for (const code of customerCodes) {
const customer = await this.customersService.findByCode(code);
customerIds.push(customer.id);
}
const key = `${employee.id}:${purpose}:${cycleNumber}`;
const current = grouped.get(key) ?? {
employeeId: employee.id,
purpose,
cycleNumber,
weekdays: {},
};
const existingDay = current.weekdays[weekday];
grouped.set(key, {
...current,
weekdays: {
...current.weekdays,
[weekday]: {
startBranchId: start.id,
endBranchId: end.id,
customerIds: [
...(existingDay?.customerIds ?? []),
...customerIds,
],
},
},
});
} catch (error) {
const reason =
error instanceof BadRequestException ||
error instanceof NotFoundException
? error.message
: 'invalid data';
errors.push(`row ${rowNum}: ${reason}`);
}
}
if (errors.length > 0) {
throw new BadRequestException({
message: 'CSV validation failed',
errors,
});
}
return [...grouped.values()];
}
private async requireCycle(id: string): Promise<Cycle> {
const cycle = await this.cyclesRepository.findById(id);
if (!cycle) {
throw new NotFoundException('Cycle not found');
}
return cycle;
}
private async assertUnique(
employeeId: string,
purpose: FieldPurpose,
cycleNumber: number,
excludeId?: string,
): Promise<void> {
const existing = await this.cyclesRepository.findLiveByKey(
employeeId,
purpose,
cycleNumber,
excludeId,
);
if (existing) {
throw new ConflictException('Cycle already exists for this employee');
}
}
private async allowedPurposes(
user: AuthUser,
action: 'view' | 'update' | 'delete',
): Promise<FieldPurpose[]> {
if (user.isSuperadmin) {
return ['sales', 'logistics'];
}
const allowed: FieldPurpose[] = [];
for (const purpose of ['sales', 'logistics'] as const) {
const ok = await this.privilegesService.checkPermission(
user.id,
fieldPrivilegeKey('cycle', purpose),
action,
);
if (ok) {
allowed.push(purpose);
}
}
return allowed;
}
private async assertCanAccess(
user: AuthUser,
purpose: FieldPurpose,
action: 'view' | 'update' | 'delete',
): Promise<void> {
if (user.isSuperadmin) {
return;
}
const ok = await this.privilegesService.checkPermission(
user.id,
fieldPrivilegeKey('cycle', purpose),
action,
);
if (!ok) {
throw new ForbiddenException('Insufficient privilege');
}
}
private async assertIdsAccessible(
ids: string[],
user: AuthUser,
action: 'update' | 'delete',
): Promise<void> {
for (const id of ids) {
const cycle = await this.requireCycle(id);
await this.assertCanAccess(user, cycle.purpose, action);
}
}
private assertPurpose(raw: string): FieldPurpose {
if (!isFieldPurpose(raw)) {
throw new BadRequestException('Invalid purpose');
}
return raw;
}
private assertStatus(raw: string): Status {
try {
return Status.create(raw);
} catch (error) {
if (error instanceof InvalidStatusError) {
throw new BadRequestException('Invalid status');
}
throw error;
}
}
}
+205
View File
@@ -0,0 +1,205 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayNotEmpty,
IsArray,
IsIn,
IsInt,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsUUID,
Min,
ValidateNested,
} from 'class-validator';
import { PaginationQueryDto } from '../../../../common/http/response';
import { CORE_STATUSES } from '../../../../common/value-objects/status/status';
import { FIELD_PURPOSES, WEEKDAY_NAMES } from '../../shared/field-purpose';
import { RouteGeometryDto } from '../../shared/route-geometry.dto';
export class CycleWeekdayBodyDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
startBranchId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
endBranchId!: string;
@ApiProperty({ type: [String], format: 'uuid' })
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
customerIds!: string[];
}
export class CreateCycleDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
employeeId!: string;
@ApiProperty({ enum: FIELD_PURPOSES })
@IsIn([...FIELD_PURPOSES])
purpose!: string;
@ApiProperty({ example: 1, minimum: 1 })
@IsInt()
@Min(1)
cycleNumber!: number;
@ApiProperty({
type: 'object',
additionalProperties: { $ref: '#/components/schemas/CycleWeekdayBodyDto' },
})
@IsObject()
weekdays!: Record<string, CycleWeekdayBodyDto>;
@ApiPropertyOptional({ enum: CORE_STATUSES })
@IsOptional()
@IsIn([...CORE_STATUSES])
status?: string;
}
export class UpdateCycleDto {
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
employeeId?: string;
@ApiPropertyOptional({ enum: FIELD_PURPOSES })
@IsOptional()
@IsIn([...FIELD_PURPOSES])
purpose?: string;
@ApiPropertyOptional({ minimum: 1 })
@IsOptional()
@IsInt()
@Min(1)
cycleNumber?: number;
@ApiPropertyOptional()
@IsOptional()
@IsObject()
weekdays?: Record<string, CycleWeekdayBodyDto>;
}
export class UpdateCycleStatusDto {
@ApiProperty({ enum: CORE_STATUSES })
@IsIn([...CORE_STATUSES])
status!: string;
}
export class BulkIdsDto {
@ApiProperty({ type: [String], format: 'uuid' })
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
ids!: string[];
}
export class BulkStatusDto {
@ApiProperty({ type: [String], format: 'uuid' })
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
ids!: string[];
@ApiProperty({ enum: CORE_STATUSES })
@IsIn([...CORE_STATUSES])
status!: string;
}
export class ListCyclesQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
employeeId?: string;
@ApiPropertyOptional({ enum: FIELD_PURPOSES })
@IsOptional()
@IsIn([...FIELD_PURPOSES])
purpose?: string;
@ApiPropertyOptional()
@IsOptional()
@Type(() => Number)
@IsInt()
cycleNumber?: number;
@ApiPropertyOptional({ enum: CORE_STATUSES })
@IsOptional()
@IsIn([...CORE_STATUSES])
status?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@IsNotEmpty()
search?: string;
}
export class CycleDestinationDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ format: 'uuid' })
customerId!: string;
@ApiProperty()
sortOrder!: number;
}
export class CycleWeekdayDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ enum: WEEKDAY_NAMES })
weekday!: string;
@ApiProperty({ format: 'uuid' })
startBranchId!: string;
@ApiProperty({ format: 'uuid' })
endBranchId!: string;
@ApiProperty({ type: RouteGeometryDto })
routeGeometry!: RouteGeometryDto;
@ApiProperty({ type: [CycleDestinationDto] })
destinations!: CycleDestinationDto[];
}
export class CycleDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ format: 'uuid' })
employeeId!: string;
@ApiProperty({ enum: FIELD_PURPOSES })
purpose!: string;
@ApiProperty()
cycleNumber!: number;
@ApiProperty({ type: [CycleWeekdayDto] })
weekdays!: CycleWeekdayDto[];
@ApiProperty({ enum: CORE_STATUSES })
status!: string;
@ApiProperty()
createdAt!: number;
@ApiProperty()
updatedAt!: number;
@ApiProperty({ format: 'uuid' })
createdBy!: string;
@ApiProperty({ format: 'uuid' })
updatedBy!: string;
}
void ValidateNested;
+48
View File
@@ -0,0 +1,48 @@
import { Module } from '@nestjs/common';
import { BranchesModule } from '../configuration/branches/branches.module';
import { CustomersModule } from '../configuration/customers/customers.module';
import { EmployeesModule } from '../configuration/employees/employees.module';
import { PrivilegesModule } from '../privileges/privileges.module';
import { PackingSlipsModule } from '../sales/packing-slips/packing-slips.module';
import { SalesInvoicesModule } from '../sales/sales-invoices/sales-invoices.module';
import { CyclesReadController } from './cycles/cycles-read.controller';
import { CyclesWriteController } from './cycles/cycles-write.controller';
import { CyclesRepository } from './cycles/cycles.repository';
import { CyclesService } from './cycles/cycles.service';
import { PlansReadController } from './plans/plans-read.controller';
import { PlansWriteController } from './plans/plans-write.controller';
import { PlansRepository } from './plans/plans.repository';
import { PlansService } from './plans/plans.service';
import { CompanySettingsController } from './settings/company-settings.controller';
import { CompanySettingsRepository } from './settings/company-settings.repository';
import { CompanySettingsService } from './settings/company-settings.service';
import { FieldPrivilegeGuard } from './shared/field-privilege.guard';
@Module({
imports: [
PrivilegesModule,
EmployeesModule,
BranchesModule,
CustomersModule,
SalesInvoicesModule,
PackingSlipsModule,
],
controllers: [
CompanySettingsController,
CyclesReadController,
CyclesWriteController,
PlansReadController,
PlansWriteController,
],
providers: [
FieldPrivilegeGuard,
CompanySettingsRepository,
CompanySettingsService,
CyclesRepository,
CyclesService,
PlansRepository,
PlansService,
],
exports: [CompanySettingsService, CyclesService, PlansService],
})
export class FieldModule {}
+256
View File
@@ -0,0 +1,256 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayNotEmpty,
IsArray,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
Matches,
} from 'class-validator';
import { PaginationQueryDto } from '../../../../common/http/response';
import { CORE_STATUSES } from '../../../../common/value-objects/status/status';
import { FIELD_PURPOSES } from '../../shared/field-purpose';
import { RouteGeometryDto } from '../../shared/route-geometry.dto';
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
export class CreatePlanDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
employeeId!: string;
@ApiProperty({ enum: FIELD_PURPOSES })
@IsIn([...FIELD_PURPOSES])
purpose!: string;
@ApiProperty({ example: '2026-01-12' })
@IsString()
@Matches(DATE_PATTERN)
date!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
startBranchId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
endBranchId!: string;
@ApiProperty({ type: [String], format: 'uuid' })
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
customerIds!: string[];
@ApiPropertyOptional({ type: [String], format: 'uuid' })
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
invoiceIds?: string[];
@ApiPropertyOptional({ type: [String], format: 'uuid' })
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
packingSlipIds?: string[];
@ApiPropertyOptional({ enum: CORE_STATUSES })
@IsOptional()
@IsIn([...CORE_STATUSES])
status?: string;
}
export class UpdatePlanDto {
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
employeeId?: string;
@ApiPropertyOptional({ enum: FIELD_PURPOSES })
@IsOptional()
@IsIn([...FIELD_PURPOSES])
purpose?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@Matches(DATE_PATTERN)
date?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
startBranchId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
endBranchId?: string;
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
customerIds?: string[];
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
invoiceIds?: string[];
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
packingSlipIds?: string[];
}
export class UpdatePlanStatusDto {
@ApiProperty({ enum: CORE_STATUSES })
@IsIn([...CORE_STATUSES])
status!: string;
}
export class BulkIdsDto {
@ApiProperty({ type: [String], format: 'uuid' })
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
ids!: string[];
}
export class BulkStatusDto {
@ApiProperty({ type: [String], format: 'uuid' })
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
ids!: string[];
@ApiProperty({ enum: CORE_STATUSES })
@IsIn([...CORE_STATUSES])
status!: string;
}
export class GeneratePlansDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
employeeId!: string;
@ApiProperty({ enum: FIELD_PURPOSES })
@IsIn([...FIELD_PURPOSES])
purpose!: string;
@ApiProperty({ example: '2026-01-12' })
@IsString()
@Matches(DATE_PATTERN)
from!: string;
@ApiProperty({ example: '2026-01-25' })
@IsString()
@Matches(DATE_PATTERN)
to!: string;
}
export class AddPlanDestinationDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
customerId!: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
afterDestinationId?: string;
}
export class ListPlansQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
employeeId?: string;
@ApiPropertyOptional({ enum: FIELD_PURPOSES })
@IsOptional()
@IsIn([...FIELD_PURPOSES])
purpose?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@Matches(DATE_PATTERN)
date?: string;
@ApiPropertyOptional({ enum: CORE_STATUSES })
@IsOptional()
@IsIn([...CORE_STATUSES])
status?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@IsNotEmpty()
search?: string;
}
export class PlanDestinationDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ format: 'uuid' })
customerId!: string;
@ApiProperty()
sortOrder!: number;
}
export class PlanDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ format: 'uuid' })
employeeId!: string;
@ApiProperty({ enum: FIELD_PURPOSES })
purpose!: string;
@ApiProperty()
date!: number;
@ApiProperty({ format: 'uuid' })
startBranchId!: string;
@ApiProperty({ format: 'uuid' })
endBranchId!: string;
@ApiProperty({ type: RouteGeometryDto })
routeGeometry!: RouteGeometryDto;
@ApiProperty({ type: [PlanDestinationDto] })
destinations!: PlanDestinationDto[];
@ApiProperty({ type: [String] })
invoiceIds!: string[];
@ApiProperty({ type: [String] })
packingSlipIds!: string[];
@ApiProperty({ enum: CORE_STATUSES })
status!: string;
@ApiProperty()
createdAt!: number;
@ApiProperty()
updatedAt!: number;
@ApiProperty({ format: 'uuid' })
createdBy!: string;
@ApiProperty({ format: 'uuid' })
updatedBy!: string;
}
void Type;
+43
View File
@@ -0,0 +1,43 @@
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
import type { FieldPurpose } from '../shared/field-purpose';
import type { RouteGeometry } from '../shared/route-line-string';
export type PlanDestination = {
readonly id: string;
readonly customerId: string;
readonly sortOrder: number;
};
export type Plan = {
readonly id: string;
readonly employeeId: string;
readonly purpose: FieldPurpose;
readonly date: DateTime;
readonly startBranchId: string;
readonly endBranchId: string;
readonly routeGeometry: RouteGeometry;
readonly destinations: readonly PlanDestination[];
readonly invoiceIds: readonly string[];
readonly packingSlipIds: readonly string[];
readonly status: Status;
readonly createdAt: DateTime;
readonly updatedAt: DateTime;
readonly createdBy: string;
readonly updatedBy: string;
};
export type PersistablePlanDestination = {
readonly customerId: string;
};
export type ListPlansFilters = {
readonly employeeId?: string;
readonly purpose?: string;
readonly date?: number;
readonly status?: string;
readonly search?: string;
readonly purposes?: readonly string[];
readonly limit: number;
readonly offset: number;
};
@@ -0,0 +1,37 @@
import { Test, TestingModule } from '@nestjs/testing';
import { FieldPrivilegeGuard } from '../shared/field-privilege.guard';
import { PlansReadController } from './plans-read.controller';
import { PlansService } from './plans.service';
describe('PlansReadController', () => {
let controller: PlansReadController;
const service = { list: jest.fn(), findById: jest.fn() };
const user = { id: 'user-1', isSuperadmin: true };
beforeEach(async () => {
jest.clearAllMocks();
const moduleRef: TestingModule = await Test.createTestingModule({
controllers: [PlansReadController],
providers: [{ provide: PlansService, useValue: service }],
})
.overrideGuard(FieldPrivilegeGuard)
.useValue({ canActivate: () => true })
.compile();
controller = moduleRef.get(PlansReadController);
});
it('list delegates to the service', async () => {
service.list.mockResolvedValue({ data: [], total: 0 });
await expect(controller.list({ page: 1 }, user as never)).resolves.toEqual({
data: [],
total: 0,
});
});
it('findOne delegates to the service', async () => {
service.findById.mockResolvedValue({ id: 'pln-1' });
await expect(controller.findOne('pln-1', user as never)).resolves.toEqual({
id: 'pln-1',
});
});
});
@@ -0,0 +1,77 @@
import {
Controller,
Get,
Param,
ParseUUIDPipe,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiForbiddenResponse,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import type { AuthUser } from '../../../common/auth/auth-user';
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
import {
Pagination,
type PaginationResponse,
PaginationMetaDto,
} from '../../../common/http/response';
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
import { RequireFieldPrivilege } from '../shared/field-privilege.decorator';
import { FieldPrivilegeGuard } from '../shared/field-privilege.guard';
import { ListPlansQueryDto, PlanDto } from './dto/plan.dto';
import { PlansService } from './plans.service';
@ApiTags('plans')
@ApiBearerAuth(BEARER_AUTH_NAME)
@UseGuards(FieldPrivilegeGuard)
@Controller('plans')
export class PlansReadController {
constructor(private readonly plansService: PlansService) {}
@Get()
@Pagination()
@RequireFieldPrivilege('plan', 'view')
@ApiOperation({ summary: 'List plans' })
@ApiOkResponse({
schema: {
properties: {
data: {
type: 'array',
items: { $ref: '#/components/schemas/PlanDto' },
},
meta: { $ref: '#/components/schemas/PaginationMetaDto' },
},
},
})
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
list(
@Query() query: ListPlansQueryDto,
@CurrentUser() user: AuthUser,
): Promise<PaginationResponse<PlanDto>> {
return this.plansService.list(query, user);
}
@Get(':id')
@RequireFieldPrivilege('plan', 'view')
@ApiOperation({ summary: 'Get plan detail' })
@ApiOkResponse({ type: PlanDto })
@ApiNotFoundResponse()
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
findOne(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUser,
): Promise<PlanDto> {
return this.plansService.findById(id, user);
}
}
void PaginationMetaDto;
@@ -0,0 +1,68 @@
import { Test, TestingModule } from '@nestjs/testing';
import { FieldPrivilegeGuard } from '../shared/field-privilege.guard';
import { PlansWriteController } from './plans-write.controller';
import { PlansService } from './plans.service';
describe('PlansWriteController', () => {
let controller: PlansWriteController;
const service = {
create: jest.fn(),
generate: jest.fn(),
addDestination: jest.fn(),
removeDestination: jest.fn(),
delete: jest.fn(),
};
const user = {
id: 'user-1',
username: 'alice',
jti: 'j',
isSuperadmin: true,
};
beforeEach(async () => {
jest.clearAllMocks();
const moduleRef: TestingModule = await Test.createTestingModule({
controllers: [PlansWriteController],
providers: [{ provide: PlansService, useValue: service }],
})
.overrideGuard(FieldPrivilegeGuard)
.useValue({ canActivate: () => true })
.compile();
controller = moduleRef.get(PlansWriteController);
});
it('generate delegates', async () => {
service.generate.mockResolvedValue({ created: 2, skipped: 1 });
await controller.generate(
{
employeeId: 'emp-1',
purpose: 'sales',
from: '2026-01-05',
to: '2026-01-11',
},
'user-1',
);
expect(service.generate).toHaveBeenCalledWith({
employeeId: 'emp-1',
purpose: 'sales',
from: '2026-01-05',
to: '2026-01-11',
userId: 'user-1',
});
});
it('addDestination delegates', async () => {
service.addDestination.mockResolvedValue({ id: 'pln-1' });
await controller.addDestination(
'pln-1',
{ customerId: 'cus-2' },
user as never,
);
expect(service.addDestination).toHaveBeenCalledWith(
'pln-1',
'cus-2',
undefined,
user,
);
});
});
@@ -0,0 +1,174 @@
import {
Body,
Controller,
Delete,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiCreatedResponse,
ApiForbiddenResponse,
ApiNoContentResponse,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import type { AuthUser } from '../../../common/auth/auth-user';
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
import { RequireFieldPrivilege } from '../shared/field-privilege.decorator';
import { FieldPrivilegeGuard } from '../shared/field-privilege.guard';
import {
AddPlanDestinationDto,
BulkIdsDto,
BulkStatusDto,
CreatePlanDto,
GeneratePlansDto,
PlanDto,
UpdatePlanDto,
UpdatePlanStatusDto,
} from './dto/plan.dto';
import { PlansService } from './plans.service';
@ApiTags('plans')
@ApiBearerAuth(BEARER_AUTH_NAME)
@UseGuards(FieldPrivilegeGuard)
@Controller('plans')
export class PlansWriteController {
constructor(private readonly plansService: PlansService) {}
@Post('generate')
@RequireFieldPrivilege('plan', 'create')
@ApiOperation({ summary: 'Generate plans from cycles for a date range' })
@ApiOkResponse({
schema: {
properties: {
created: { type: 'number' },
skipped: { type: 'number' },
},
},
})
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
generate(
@Body() dto: GeneratePlansDto,
@CurrentUser('id') userId: string,
): Promise<{ created: number; skipped: number }> {
return this.plansService.generate({ ...dto, userId });
}
@Post('bulk-delete')
@HttpCode(200)
@RequireFieldPrivilege('plan', 'delete')
@ApiOperation({ summary: 'Bulk archive plans' })
@ApiOkResponse({ schema: { properties: { deleted: { type: 'number' } } } })
bulkDelete(
@Body() dto: BulkIdsDto,
@CurrentUser() user: AuthUser,
): Promise<{ deleted: number }> {
return this.plansService.bulkDelete(dto.ids, user);
}
@Post('bulk-status')
@HttpCode(200)
@RequireFieldPrivilege('plan', 'update')
@ApiOperation({ summary: 'Bulk update plan status' })
@ApiOkResponse({ schema: { properties: { updated: { type: 'number' } } } })
bulkStatus(
@Body() dto: BulkStatusDto,
@CurrentUser() user: AuthUser,
): Promise<{ updated: number }> {
return this.plansService.bulkUpdateStatus(
dto.ids,
dto.status,
user.id,
user,
);
}
@Post()
@RequireFieldPrivilege('plan', 'create')
@ApiOperation({ summary: 'Create plan' })
@ApiCreatedResponse({ type: PlanDto })
create(
@Body() dto: CreatePlanDto,
@CurrentUser('id') userId: string,
): Promise<PlanDto> {
return this.plansService.create({ ...dto, userId });
}
@Post(':id/destinations')
@RequireFieldPrivilege('plan', 'update')
@ApiOperation({ summary: 'Add a destination to a plan' })
@ApiOkResponse({ type: PlanDto })
addDestination(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AddPlanDestinationDto,
@CurrentUser() user: AuthUser,
): Promise<PlanDto> {
return this.plansService.addDestination(
id,
dto.customerId,
dto.afterDestinationId,
user,
);
}
@Delete(':id/destinations/:destinationId')
@RequireFieldPrivilege('plan', 'update')
@ApiOperation({ summary: 'Remove a destination from a plan' })
@ApiOkResponse({ type: PlanDto })
removeDestination(
@Param('id', ParseUUIDPipe) id: string,
@Param('destinationId', ParseUUIDPipe) destinationId: string,
@CurrentUser() user: AuthUser,
): Promise<PlanDto> {
return this.plansService.removeDestination(id, destinationId, user);
}
@Patch(':id/status')
@RequireFieldPrivilege('plan', 'update')
@ApiOperation({ summary: 'Update plan status' })
@ApiOkResponse({ type: PlanDto })
updateStatus(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdatePlanStatusDto,
@CurrentUser() user: AuthUser,
): Promise<PlanDto> {
return this.plansService.updateStatus(id, dto.status, user.id, user);
}
@Patch(':id')
@RequireFieldPrivilege('plan', 'update')
@ApiOperation({ summary: 'Update plan (not status)' })
@ApiOkResponse({ type: PlanDto })
@ApiNotFoundResponse()
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdatePlanDto,
@CurrentUser() user: AuthUser,
): Promise<PlanDto> {
return this.plansService.update(id, { ...dto, userId: user.id }, user);
}
@Delete(':id')
@HttpCode(204)
@RequireFieldPrivilege('plan', 'delete')
@ApiOperation({ summary: 'Archive plan' })
@ApiNoContentResponse()
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
async delete(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUser,
): Promise<void> {
await this.plansService.delete(id, user);
}
}
+466
View File
@@ -0,0 +1,466 @@
import {
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
and,
asc,
count,
eq,
ilike,
inArray,
ne,
or,
sql,
SQL,
} from 'drizzle-orm';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
import {
planDestinations,
planInvoices,
planPackingSlips,
plans,
type PlanDestinationRow,
type PlanInvoiceRow,
type PlanPackingSlipRow,
type PlanRow,
} from '../../../database/plans-table';
import type { FieldPurpose } from '../shared/field-purpose';
import type { RouteGeometry } from '../shared/route-line-string';
import type { ListPlansFilters, Plan } from './plan';
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete' | 'update'>;
@Injectable()
export class PlansRepository {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
async list(
filters: ListPlansFilters,
): Promise<{ data: Plan[]; total: number }> {
const where = this.buildListWhere(filters);
const totalRows = await this.db
.select({ total: count() })
.from(plans)
.where(where);
let qb = this.db.select().from(plans).$dynamic();
qb = this.extendListQuery(qb, filters);
const rows = await qb
.where(where)
.orderBy(asc(plans.date))
.limit(filters.limit)
.offset(filters.offset);
return {
data: rows.map((row) => this.toDomain(row, [], [], [])),
total: Number(totalRows[0]?.total ?? 0),
};
}
extendListQuery<T>(qb: T, filters: ListPlansFilters): T {
void filters;
return qb;
}
async findById(id: string): Promise<Plan | null> {
const rows: PlanRow[] = await this.db
.select()
.from(plans)
.where(eq(plans.id, id))
.limit(1);
const row = rows[0];
if (!row) {
return null;
}
return this.loadWithChildren(this.db, row);
}
async findLiveByKey(
employeeId: string,
purpose: string,
date: number,
excludeId?: string,
): Promise<Plan | null> {
const parts: SQL[] = [
eq(plans.employeeId, employeeId),
eq(plans.purpose, purpose),
eq(plans.date, date),
ne(plans.status, 'archived'),
];
if (excludeId) {
parts.push(ne(plans.id, excludeId));
}
const rows = await this.db
.select()
.from(plans)
.where(and(...parts))
.limit(1);
const row = rows[0];
return row ? this.toDomain(row, [], [], []) : null;
}
async create(input: {
employeeId: string;
purpose: FieldPurpose;
date: DateTime;
startBranchId: string;
endBranchId: string;
routeGeometry: RouteGeometry;
customerIds: readonly string[];
invoiceIds: readonly string[];
packingSlipIds: readonly string[];
status: Status;
userId: string;
}): Promise<Plan> {
const now = DateTime.fromUnixMs(Date.now());
try {
return await this.db.transaction(async (tx) => {
const inserted = await tx
.insert(plans)
.values({
employeeId: input.employeeId,
purpose: input.purpose,
date: input.date.value,
startBranchId: input.startBranchId,
endBranchId: input.endBranchId,
routeGeometry: input.routeGeometry,
status: input.status.value,
createdAt: now.value,
updatedAt: now.value,
createdBy: input.userId,
updatedBy: input.userId,
})
.returning();
const row = inserted[0];
await this.replaceChildren(tx, row.id, input);
return this.loadWithChildren(tx, row);
});
} catch (error) {
this.rethrowConstraintViolation(error);
}
}
async update(
id: string,
input: {
employeeId: string;
purpose: FieldPurpose;
date: DateTime;
startBranchId: string;
endBranchId: string;
routeGeometry: RouteGeometry;
customerIds?: readonly string[];
invoiceIds?: readonly string[];
packingSlipIds?: readonly string[];
userId: string;
},
): Promise<Plan> {
const existing = await this.findById(id);
if (!existing) {
throw new NotFoundException('Plan not found');
}
const now = DateTime.fromUnixMs(Date.now());
try {
return await this.db.transaction(async (tx) => {
const updated = await tx
.update(plans)
.set({
employeeId: input.employeeId,
purpose: input.purpose,
date: input.date.value,
startBranchId: input.startBranchId,
endBranchId: input.endBranchId,
routeGeometry: input.routeGeometry,
updatedAt: now.value,
updatedBy: input.userId,
})
.where(eq(plans.id, id))
.returning();
const row = updated[0];
await this.replaceChildren(tx, id, {
customerIds: input.customerIds,
invoiceIds: input.invoiceIds,
packingSlipIds: input.packingSlipIds,
});
return this.loadWithChildren(tx, row);
});
} catch (error) {
this.rethrowConstraintViolation(error);
}
}
async replaceDestinations(
id: string,
customerIds: readonly string[],
routeGeometry: RouteGeometry,
userId: string,
): Promise<Plan> {
const existing = await this.findById(id);
if (!existing) {
throw new NotFoundException('Plan not found');
}
const now = DateTime.fromUnixMs(Date.now());
return this.db.transaction(async (tx) => {
await tx
.update(plans)
.set({
routeGeometry,
updatedAt: now.value,
updatedBy: userId,
})
.where(eq(plans.id, id));
await tx.delete(planDestinations).where(eq(planDestinations.planId, id));
if (customerIds.length > 0) {
await tx.insert(planDestinations).values(
customerIds.map((customerId, index) => ({
planId: id,
customerId,
sortOrder: index,
})),
);
}
const rows = await tx
.select()
.from(plans)
.where(eq(plans.id, id))
.limit(1);
return this.loadWithChildren(tx, rows[0]);
});
}
async updateStatus(
id: string,
status: Status,
userId: string,
): Promise<Plan> {
const now = DateTime.fromUnixMs(Date.now());
const updated = await this.db
.update(plans)
.set({
status: status.value,
updatedAt: now.value,
updatedBy: userId,
})
.where(eq(plans.id, id))
.returning({ id: plans.id });
if (updated.length === 0) {
throw new NotFoundException('Plan not found');
}
const found = await this.findById(id);
if (!found) {
throw new NotFoundException('Plan not found');
}
return found;
}
async bulkUpdateStatus(
ids: string[],
status: Status,
userId: string,
): Promise<number> {
if (ids.length === 0) {
return 0;
}
const now = DateTime.fromUnixMs(Date.now());
const rows = await this.db
.update(plans)
.set({
status: status.value,
updatedAt: now.value,
updatedBy: userId,
})
.where(inArray(plans.id, ids))
.returning({ id: plans.id });
return rows.length;
}
async archive(id: string, userId: string): Promise<void> {
const now = DateTime.fromUnixMs(Date.now());
const updated = await this.db
.update(plans)
.set({
status: 'archived',
updatedAt: now.value,
updatedBy: userId,
})
.where(eq(plans.id, id))
.returning({ id: plans.id });
if (updated.length === 0) {
throw new NotFoundException('Plan not found');
}
}
async bulkArchive(ids: string[], userId: string): Promise<number> {
if (ids.length === 0) {
return 0;
}
const now = DateTime.fromUnixMs(Date.now());
const rows = await this.db
.update(plans)
.set({
status: 'archived',
updatedAt: now.value,
updatedBy: userId,
})
.where(inArray(plans.id, ids))
.returning({ id: plans.id });
return rows.length;
}
private async loadWithChildren(
executor: QueryExecutor,
row: PlanRow,
): Promise<Plan> {
const destinations = await executor
.select()
.from(planDestinations)
.where(eq(planDestinations.planId, row.id))
.orderBy(asc(planDestinations.sortOrder));
const invoices = await executor
.select()
.from(planInvoices)
.where(eq(planInvoices.planId, row.id));
const packingSlips = await executor
.select()
.from(planPackingSlips)
.where(eq(planPackingSlips.planId, row.id));
return this.toDomain(row, destinations, invoices, packingSlips);
}
private async replaceChildren(
executor: QueryExecutor,
planId: string,
input: {
customerIds?: readonly string[];
invoiceIds?: readonly string[];
packingSlipIds?: readonly string[];
},
): Promise<void> {
if (input.customerIds) {
await executor
.delete(planDestinations)
.where(eq(planDestinations.planId, planId));
if (input.customerIds.length > 0) {
await executor.insert(planDestinations).values(
input.customerIds.map((customerId, index) => ({
planId,
customerId,
sortOrder: index,
})),
);
}
}
if (input.invoiceIds) {
await executor
.delete(planInvoices)
.where(eq(planInvoices.planId, planId));
if (input.invoiceIds.length > 0) {
await executor
.insert(planInvoices)
.values(input.invoiceIds.map((invoiceId) => ({ planId, invoiceId })));
}
}
if (input.packingSlipIds) {
await executor
.delete(planPackingSlips)
.where(eq(planPackingSlips.planId, planId));
if (input.packingSlipIds.length > 0) {
await executor.insert(planPackingSlips).values(
input.packingSlipIds.map((packingSlipId) => ({
planId,
packingSlipId,
})),
);
}
}
}
private buildListWhere(filters: ListPlansFilters): SQL | undefined {
const parts: SQL[] = [];
if (filters.employeeId) {
parts.push(eq(plans.employeeId, filters.employeeId));
}
if (filters.purpose) {
parts.push(eq(plans.purpose, filters.purpose));
} else if (filters.purposes && filters.purposes.length > 0) {
parts.push(inArray(plans.purpose, [...filters.purposes]));
}
if (filters.date !== undefined) {
parts.push(eq(plans.date, filters.date));
}
if (filters.status) {
parts.push(eq(plans.status, filters.status));
}
if (filters.search) {
const search = or(
ilike(plans.purpose, `%${filters.search}%`),
sql`${plans.date}::text ilike ${'%' + filters.search + '%'}`,
);
if (search) {
parts.push(search);
}
}
if (parts.length === 0) {
return undefined;
}
return parts.length === 1 ? parts[0] : and(...parts);
}
private toDomain(
row: PlanRow,
destinationRows: PlanDestinationRow[],
invoiceRows: PlanInvoiceRow[],
packingSlipRows: PlanPackingSlipRow[],
): Plan {
return {
id: row.id,
employeeId: row.employeeId,
purpose: row.purpose as FieldPurpose,
date: DateTime.fromUnixMs(row.date),
startBranchId: row.startBranchId,
endBranchId: row.endBranchId,
routeGeometry: row.routeGeometry,
destinations: destinationRows.map((destination) => ({
id: destination.id,
customerId: destination.customerId,
sortOrder: destination.sortOrder,
})),
invoiceIds: invoiceRows.map((row) => row.invoiceId),
packingSlipIds: packingSlipRows.map((row) => row.packingSlipId),
status: Status.create(row.status),
createdAt: DateTime.fromUnixMs(row.createdAt),
updatedAt: DateTime.fromUnixMs(row.updatedAt),
createdBy: row.createdBy,
updatedBy: row.updatedBy,
};
}
private rethrowConstraintViolation(error: unknown): never {
const err = this.unwrapDbError(error);
if (err.code === '23505') {
throw new ConflictException('Plan already exists for this employee');
}
if (err.code === '23503') {
throw new ConflictException('Plan references a missing record');
}
throw error;
}
private unwrapDbError(error: unknown): { code?: string } {
let current: unknown = error;
for (let i = 0; i < 5; i++) {
if (!current || typeof current !== 'object') {
break;
}
const obj = current as { code?: string; cause?: unknown };
if (obj.code === '23505' || obj.code === '23503') {
return { code: obj.code };
}
current = obj.cause;
}
return error as { code?: string };
}
}
@@ -0,0 +1,213 @@
import { BadRequestException, ConflictException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import type { AuthUser } from '../../../common/auth/auth-user';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
import { BranchesService } from '../../configuration/branches/branches.service';
import { CustomersService } from '../../configuration/customers/customers.service';
import { EmployeesService } from '../../configuration/employees/employees.service';
import { PrivilegesService } from '../../privileges/privileges.service';
import { PackingSlipsService } from '../../sales/packing-slips/packing-slips.service';
import { SalesInvoicesService } from '../../sales/sales-invoices/sales-invoices.service';
import type { Cycle } from '../cycles/cycle';
import { CyclesRepository } from '../cycles/cycles.repository';
import { CompanySettingsService } from '../settings/company-settings.service';
import type { Plan } from './plan';
import { PlansRepository } from './plans.repository';
import { PlansService } from './plans.service';
describe('PlansService', () => {
let service: PlansService;
let plansRepository: jest.Mocked<
Pick<
PlansRepository,
| 'list'
| 'findById'
| 'findLiveByKey'
| 'create'
| 'update'
| 'replaceDestinations'
| 'updateStatus'
| 'archive'
>
>;
const cyclesRepository = { listActiveByEmployeePurpose: jest.fn() };
const companySettingsService = { requireCycleStartDate: jest.fn() };
const employeesService = { findById: jest.fn() };
const branchesService = { findById: jest.fn() };
const customersService = { findById: jest.fn() };
const salesInvoicesService = { findById: jest.fn() };
const packingSlipsService = { findById: jest.fn() };
const privilegesService = { checkPermission: jest.fn() };
const user: AuthUser = {
id: 'user-1',
username: 'alice',
jti: 'jti-1',
isSuperadmin: true,
};
const now = DateTime.fromUnixMs(1_700_000_000_000);
const geometry = {
type: 'LineString' as const,
coordinates: [
[106.8, -6.2],
[106.9, -6.3],
[107.0, -6.4],
] as const,
};
const located = { id: 'x', latitude: -6.2, longitude: 106.8 };
const cycle: Cycle = {
id: 'cyc-1',
employeeId: 'emp-1',
purpose: 'sales',
cycleNumber: 1,
weekdays: [
{
id: 'wd-1',
weekday: 'monday',
startBranchId: 'br-1',
endBranchId: 'br-2',
routeGeometry: geometry,
destinations: [{ id: 'cd-1', customerId: 'cus-1', sortOrder: 0 }],
},
],
status: Status.create('active'),
createdAt: now,
updatedAt: now,
createdBy: 'user-1',
updatedBy: 'user-1',
};
const plan: Plan = {
id: 'pln-1',
employeeId: 'emp-1',
purpose: 'sales',
date: DateTime.create('2026-01-05'),
startBranchId: 'br-1',
endBranchId: 'br-2',
routeGeometry: geometry,
destinations: [{ id: 'pd-1', customerId: 'cus-1', sortOrder: 0 }],
invoiceIds: [],
packingSlipIds: [],
status: Status.create('active'),
createdAt: now,
updatedAt: now,
createdBy: 'user-1',
updatedBy: 'user-1',
};
beforeEach(async () => {
jest.clearAllMocks();
plansRepository = {
list: jest.fn(),
findById: jest.fn(),
findLiveByKey: jest.fn(),
create: jest.fn(),
update: jest.fn(),
replaceDestinations: jest.fn(),
updateStatus: jest.fn(),
archive: jest.fn(),
};
employeesService.findById.mockResolvedValue({ id: 'emp-1' });
branchesService.findById.mockResolvedValue(located);
customersService.findById.mockResolvedValue(located);
plansRepository.findLiveByKey.mockResolvedValue(null);
plansRepository.create.mockResolvedValue(plan);
companySettingsService.requireCycleStartDate.mockResolvedValue(
DateTime.create('2026-01-05'),
);
cyclesRepository.listActiveByEmployeePurpose.mockResolvedValue([cycle]);
const moduleRef: TestingModule = await Test.createTestingModule({
providers: [
PlansService,
{ provide: PlansRepository, useValue: plansRepository },
{ provide: CyclesRepository, useValue: cyclesRepository },
{ provide: CompanySettingsService, useValue: companySettingsService },
{ provide: EmployeesService, useValue: employeesService },
{ provide: BranchesService, useValue: branchesService },
{ provide: CustomersService, useValue: customersService },
{ provide: SalesInvoicesService, useValue: salesInvoicesService },
{ provide: PackingSlipsService, useValue: packingSlipsService },
{ provide: PrivilegesService, useValue: privilegesService },
],
}).compile();
service = moduleRef.get(PlansService);
});
it('rejects packing slips on a sales plan', async () => {
await expect(
service.create({
employeeId: 'emp-1',
purpose: 'sales',
date: '2026-01-05',
startBranchId: 'br-1',
endBranchId: 'br-2',
customerIds: ['cus-1'],
packingSlipIds: ['ps-1'],
userId: 'user-1',
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('generate copies a weekday and skips an existing plan', async () => {
plansRepository.findLiveByKey
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(plan);
const result = await service.generate({
employeeId: 'emp-1',
purpose: 'sales',
from: '2026-01-05',
to: '2026-01-12',
userId: 'user-1',
});
expect(result.created).toBe(1);
expect(result.skipped).toBeGreaterThan(0);
expect(plansRepository.create).toHaveBeenCalledTimes(1);
const created = plansRepository.create.mock.calls[0][0];
expect(created.invoiceIds).toEqual([]);
expect(created.status.value).toBe('active');
});
it('generate fails when the employee has no cycle', async () => {
cyclesRepository.listActiveByEmployeePurpose.mockResolvedValue([]);
await expect(
service.generate({
employeeId: 'emp-1',
purpose: 'sales',
from: '2026-01-05',
to: '2026-01-05',
userId: 'user-1',
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('refuses to remove the last destination', async () => {
plansRepository.findById.mockResolvedValue(plan);
await expect(
service.removeDestination('pln-1', 'pd-1', user),
).rejects.toBeInstanceOf(BadRequestException);
});
it('adds a destination without writing a cycle', async () => {
plansRepository.findById.mockResolvedValue(plan);
plansRepository.replaceDestinations.mockResolvedValue({
...plan,
destinations: [
...plan.destinations,
{ id: 'pd-2', customerId: 'cus-2', sortOrder: 1 },
],
});
await service.addDestination('pln-1', 'cus-2', undefined, user);
expect(plansRepository.replaceDestinations).toHaveBeenCalled();
expect(cyclesRepository.listActiveByEmployeePurpose).not.toHaveBeenCalled();
});
it('rejects a duplicate destination', async () => {
plansRepository.findById.mockResolvedValue(plan);
await expect(
service.addDestination('pln-1', 'cus-1', undefined, user),
).rejects.toBeInstanceOf(ConflictException);
});
});
+624
View File
@@ -0,0 +1,624 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { AuthUser } from '../../../common/auth/auth-user';
import type { PaginationResponse } from '../../../common/http/response';
import { toListPage } 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 { InvalidStatusError } from '../../../common/value-objects/status/invalid-status.error';
import { Status } from '../../../common/value-objects/status/status';
import { BranchesService } from '../../configuration/branches/branches.service';
import { CustomersService } from '../../configuration/customers/customers.service';
import { EmployeesService } from '../../configuration/employees/employees.service';
import { PrivilegesService } from '../../privileges/privileges.service';
import { PackingSlipsService } from '../../sales/packing-slips/packing-slips.service';
import { SalesInvoicesService } from '../../sales/sales-invoices/sales-invoices.service';
import { CyclesRepository } from '../cycles/cycles.repository';
import { CompanySettingsService } from '../settings/company-settings.service';
import {
cycleNumberForDate,
fieldPrivilegeKey,
isFieldPurpose,
noCycleMessage,
type FieldPurpose,
} from '../shared/field-purpose';
import {
buildRouteLineString,
isUsableRouteGeometry,
} from '../shared/route-line-string';
import type { Plan } from './plan';
import { PlansRepository } from './plans.repository';
export type ListPlansQuery = {
readonly employeeId?: string;
readonly purpose?: string;
readonly date?: string;
readonly status?: string;
readonly search?: string;
readonly page?: number;
readonly limit?: number;
readonly offset?: number;
};
const VISIBLE_FIELDS = [
'id',
'employeeId',
'purpose',
'date',
'startBranchId',
'endBranchId',
'routeGeometry',
'destinations',
'invoiceIds',
'packingSlipIds',
'status',
'createdAt',
'updatedAt',
'createdBy',
'updatedBy',
] as const;
const MS_PER_DAY = 86_400_000;
@Injectable()
export class PlansService {
constructor(
private readonly plansRepository: PlansRepository,
private readonly cyclesRepository: CyclesRepository,
private readonly companySettingsService: CompanySettingsService,
private readonly employeesService: EmployeesService,
private readonly branchesService: BranchesService,
private readonly customersService: CustomersService,
private readonly salesInvoicesService: SalesInvoicesService,
private readonly packingSlipsService: PackingSlipsService,
private readonly privilegesService: PrivilegesService,
) {}
async list(
query: ListPlansQuery,
user: AuthUser,
): Promise<PaginationResponse<ReturnType<PlansService['toItem']>>> {
const purposes = await this.allowedPurposes(user, 'view');
if (query.purpose && !purposes.includes(query.purpose as FieldPurpose)) {
throw new ForbiddenException('Insufficient privilege');
}
const page = toListPage(query);
const { data, total } = await this.plansRepository.list({
employeeId: query.employeeId,
purpose: query.purpose,
date: query.date
? this.assertDate(query.date).startOfDay().value
: undefined,
status: query.status,
search: query.search,
purposes: query.purpose ? undefined : purposes,
limit: page.limit,
offset: page.offset,
});
return { data: data.map((item) => this.toItem(item)), total };
}
async findById(
id: string,
user: AuthUser,
): Promise<ReturnType<PlansService['toItem']>> {
const plan = await this.requirePlan(id);
await this.assertCanAccess(user, plan.purpose, 'view');
return this.toItem(plan);
}
async create(input: {
employeeId: string;
purpose: string;
date: string;
startBranchId: string;
endBranchId: string;
customerIds: string[];
invoiceIds?: string[];
packingSlipIds?: string[];
status?: string;
userId: string;
}): Promise<ReturnType<PlansService['toItem']>> {
const created = await this.plansRepository.create(
await this.toWritePayload(input),
);
return this.toItem(created);
}
async update(
id: string,
input: {
employeeId?: string;
purpose?: string;
date?: string;
startBranchId?: string;
endBranchId?: string;
customerIds?: string[];
invoiceIds?: string[];
packingSlipIds?: string[];
status?: unknown;
userId: string;
},
user: AuthUser,
): Promise<ReturnType<PlansService['toItem']>> {
if (input.status !== undefined) {
throw new BadRequestException('status cannot be updated via PATCH');
}
const existing = await this.requirePlan(id);
await this.assertCanAccess(user, existing.purpose, 'update');
const purpose = input.purpose
? this.assertPurpose(input.purpose)
: existing.purpose;
const employeeId = input.employeeId ?? existing.employeeId;
const date = input.date
? this.assertDate(input.date).startOfDay()
: existing.date;
await this.employeesService.findById(employeeId);
await this.assertUnique(employeeId, purpose, date.value, id);
const startBranchId = input.startBranchId ?? existing.startBranchId;
const endBranchId = input.endBranchId ?? existing.endBranchId;
const customerIds =
input.customerIds ?? existing.destinations.map((d) => d.customerId);
const geometry = await this.buildGeometry(
startBranchId,
endBranchId,
customerIds,
);
const attachments = this.assertAttachments(
purpose,
input.invoiceIds ?? [...existing.invoiceIds],
input.packingSlipIds ?? [...existing.packingSlipIds],
);
if (input.invoiceIds) {
await this.assertInvoices(input.invoiceIds);
}
if (input.packingSlipIds) {
await this.assertPackingSlips(input.packingSlipIds);
}
const updated = await this.plansRepository.update(id, {
employeeId,
purpose,
date,
startBranchId,
endBranchId,
routeGeometry: geometry,
customerIds,
invoiceIds: attachments.invoiceIds,
packingSlipIds: attachments.packingSlipIds,
userId: input.userId,
});
return this.toItem(updated);
}
async updateStatus(
id: string,
statusRaw: string,
userId: string,
user: AuthUser,
): Promise<ReturnType<PlansService['toItem']>> {
const existing = await this.requirePlan(id);
await this.assertCanAccess(user, existing.purpose, 'update');
const updated = await this.plansRepository.updateStatus(
id,
this.assertStatus(statusRaw),
userId,
);
return this.toItem(updated);
}
async bulkUpdateStatus(
ids: string[],
statusRaw: string,
userId: string,
user: AuthUser,
): Promise<{ updated: number }> {
const status = this.assertStatus(statusRaw);
await this.assertIdsAccessible(ids, user, 'update');
const updated = await this.plansRepository.bulkUpdateStatus(
ids,
status,
userId,
);
return { updated };
}
async delete(id: string, user: AuthUser): Promise<void> {
const existing = await this.requirePlan(id);
await this.assertCanAccess(user, existing.purpose, 'delete');
await this.plansRepository.archive(id, user.id);
}
async bulkDelete(
ids: string[],
user: AuthUser,
): Promise<{ deleted: number }> {
await this.assertIdsAccessible(ids, user, 'delete');
const deleted = await this.plansRepository.bulkArchive(ids, user.id);
return { deleted };
}
async generate(input: {
employeeId: string;
purpose: string;
from: string;
to: string;
userId: string;
}): Promise<{ created: number; skipped: number }> {
const purpose = this.assertPurpose(input.purpose);
await this.employeesService.findById(input.employeeId);
const epoch = await this.companySettingsService.requireCycleStartDate();
const from = this.assertDate(input.from).startOfDay();
const to = this.assertDate(input.to).startOfDay();
if (to.value < from.value) {
throw new BadRequestException('Invalid date range');
}
if (from.value < epoch.startOfDay().value) {
throw new BadRequestException('Date is before the cycle start date');
}
const cycles = await this.cyclesRepository.listActiveByEmployeePurpose(
input.employeeId,
purpose,
);
if (cycles.length === 0) {
throw new BadRequestException(noCycleMessage(purpose));
}
const totalCycles = Math.max(...cycles.map((cycle) => cycle.cycleNumber));
let created = 0;
let skipped = 0;
for (let cursor = from.value; cursor <= to.value; cursor += MS_PER_DAY) {
const day = DateTime.fromUnixMs(cursor).startOfDay();
const cycleNumber = cycleNumberForDate(
day.wholeWeeksSince(epoch),
totalCycles,
);
const cycle = cycles.find((item) => item.cycleNumber === cycleNumber);
if (!cycle) {
skipped += 1;
continue;
}
const weekday = cycle.weekdays.find(
(item) => item.weekday === day.weekdayName(),
);
if (!weekday) {
skipped += 1;
continue;
}
const existing = await this.plansRepository.findLiveByKey(
input.employeeId,
purpose,
day.value,
);
if (existing) {
skipped += 1;
continue;
}
await this.plansRepository.create({
employeeId: input.employeeId,
purpose,
date: day,
startBranchId: weekday.startBranchId,
endBranchId: weekday.endBranchId,
routeGeometry: weekday.routeGeometry,
customerIds: weekday.destinations.map((d) => d.customerId),
invoiceIds: [],
packingSlipIds: [],
status: Status.create('active'),
userId: input.userId,
});
created += 1;
}
return { created, skipped };
}
async addDestination(
planId: string,
customerId: string,
afterDestinationId: string | undefined,
user: AuthUser,
): Promise<ReturnType<PlansService['toItem']>> {
const plan = await this.requirePlan(planId);
await this.assertCanAccess(user, plan.purpose, 'update');
await this.customersService.findById(customerId);
if (plan.destinations.some((d) => d.customerId === customerId)) {
throw new ConflictException('Customer is already on this plan');
}
const ids = plan.destinations.map((d) => d.customerId);
if (afterDestinationId) {
const after = plan.destinations.find((d) => d.id === afterDestinationId);
if (!after) {
throw new BadRequestException('Destination is not on this plan');
}
const index = ids.indexOf(after.customerId);
ids.splice(index + 1, 0, customerId);
} else {
ids.push(customerId);
}
const geometry = await this.buildGeometry(
plan.startBranchId,
plan.endBranchId,
ids,
);
const updated = await this.plansRepository.replaceDestinations(
planId,
ids,
geometry,
user.id,
);
return this.toItem(updated);
}
async removeDestination(
planId: string,
destinationId: string,
user: AuthUser,
): Promise<ReturnType<PlansService['toItem']>> {
const plan = await this.requirePlan(planId);
await this.assertCanAccess(user, plan.purpose, 'update');
if (plan.destinations.length <= 1) {
throw new BadRequestException(
'A live plan must keep at least one destination',
);
}
const remaining = plan.destinations.filter((d) => d.id !== destinationId);
if (remaining.length === plan.destinations.length) {
throw new NotFoundException('Destination not found');
}
const ids = remaining.map((d) => d.customerId);
const geometry = await this.buildGeometry(
plan.startBranchId,
plan.endBranchId,
ids,
);
const updated = await this.plansRepository.replaceDestinations(
planId,
ids,
geometry,
user.id,
);
return this.toItem(updated);
}
get visibleFields(): readonly string[] {
return VISIBLE_FIELDS;
}
toItem(plan: Plan) {
return {
id: plan.id,
employeeId: plan.employeeId,
purpose: plan.purpose,
date: plan.date.value,
startBranchId: plan.startBranchId,
endBranchId: plan.endBranchId,
routeGeometry: plan.routeGeometry,
destinations: plan.destinations.map((destination) => ({
id: destination.id,
customerId: destination.customerId,
sortOrder: destination.sortOrder,
})),
invoiceIds: [...plan.invoiceIds],
packingSlipIds: [...plan.packingSlipIds],
status: plan.status.value,
createdAt: plan.createdAt.value,
updatedAt: plan.updatedAt.value,
createdBy: plan.createdBy,
updatedBy: plan.updatedBy,
};
}
private async toWritePayload(input: {
employeeId: string;
purpose: string;
date: string;
startBranchId: string;
endBranchId: string;
customerIds: string[];
invoiceIds?: string[];
packingSlipIds?: string[];
status?: string;
userId: string;
}) {
const purpose = this.assertPurpose(input.purpose);
const date = this.assertDate(input.date).startOfDay();
await this.employeesService.findById(input.employeeId);
await this.assertUnique(input.employeeId, purpose, date.value);
const geometry = await this.buildGeometry(
input.startBranchId,
input.endBranchId,
input.customerIds,
);
const attachments = this.assertAttachments(
purpose,
input.invoiceIds ?? [],
input.packingSlipIds ?? [],
);
await this.assertInvoices(attachments.invoiceIds);
await this.assertPackingSlips(attachments.packingSlipIds);
return {
employeeId: input.employeeId,
purpose,
date,
startBranchId: input.startBranchId,
endBranchId: input.endBranchId,
routeGeometry: geometry,
customerIds: input.customerIds,
invoiceIds: attachments.invoiceIds,
packingSlipIds: attachments.packingSlipIds,
status: input.status
? this.assertStatus(input.status)
: Status.create(Status.DEFAULT),
userId: input.userId,
};
}
private async buildGeometry(
startBranchId: string,
endBranchId: string,
customerIds: readonly string[],
) {
if (customerIds.length === 0) {
throw new BadRequestException(
'A working day needs at least one destination',
);
}
const start = await this.branchesService.findById(startBranchId);
const end = await this.branchesService.findById(endBranchId);
const customers: Array<{
longitude: number | null;
latitude: number | null;
}> = [];
for (const customerId of customerIds) {
customers.push(await this.customersService.findById(customerId));
}
const geometry = buildRouteLineString([
{ longitude: start.longitude, latitude: start.latitude },
...customers.map((customer) => ({
longitude: customer.longitude,
latitude: customer.latitude,
})),
{ longitude: end.longitude, latitude: end.latitude },
]);
if (!isUsableRouteGeometry(geometry)) {
throw new BadRequestException('Route is incomplete');
}
return geometry;
}
private assertAttachments(
purpose: FieldPurpose,
invoiceIds: readonly string[],
packingSlipIds: readonly string[],
): { invoiceIds: string[]; packingSlipIds: string[] } {
if (purpose === 'sales' && packingSlipIds.length > 0) {
throw new BadRequestException('Sales plans cannot include packing slips');
}
if (purpose === 'logistics' && invoiceIds.length > 0) {
throw new BadRequestException('Logistics plans cannot include invoices');
}
return {
invoiceIds: [...invoiceIds],
packingSlipIds: [...packingSlipIds],
};
}
private async assertInvoices(ids: readonly string[]): Promise<void> {
for (const id of ids) {
await this.salesInvoicesService.findById(id);
}
}
private async assertPackingSlips(ids: readonly string[]): Promise<void> {
for (const id of ids) {
await this.packingSlipsService.findById(id);
}
}
private async requirePlan(id: string): Promise<Plan> {
const plan = await this.plansRepository.findById(id);
if (!plan) {
throw new NotFoundException('Plan not found');
}
return plan;
}
private async assertUnique(
employeeId: string,
purpose: FieldPurpose,
date: number,
excludeId?: string,
): Promise<void> {
const existing = await this.plansRepository.findLiveByKey(
employeeId,
purpose,
date,
excludeId,
);
if (existing) {
throw new ConflictException('Plan already exists for this employee');
}
}
private async allowedPurposes(
user: AuthUser,
action: 'view' | 'update' | 'delete',
): Promise<FieldPurpose[]> {
if (user.isSuperadmin) {
return ['sales', 'logistics'];
}
const allowed: FieldPurpose[] = [];
for (const purpose of ['sales', 'logistics'] as const) {
const ok = await this.privilegesService.checkPermission(
user.id,
fieldPrivilegeKey('plan', purpose),
action,
);
if (ok) {
allowed.push(purpose);
}
}
return allowed;
}
private async assertCanAccess(
user: AuthUser,
purpose: FieldPurpose,
action: 'view' | 'update' | 'delete',
): Promise<void> {
if (user.isSuperadmin) {
return;
}
const ok = await this.privilegesService.checkPermission(
user.id,
fieldPrivilegeKey('plan', purpose),
action,
);
if (!ok) {
throw new ForbiddenException('Insufficient privilege');
}
}
private async assertIdsAccessible(
ids: string[],
user: AuthUser,
action: 'update' | 'delete',
): Promise<void> {
for (const id of ids) {
const plan = await this.requirePlan(id);
await this.assertCanAccess(user, plan.purpose, action);
}
}
private assertPurpose(raw: string): FieldPurpose {
if (!isFieldPurpose(raw)) {
throw new BadRequestException('Invalid purpose');
}
return raw;
}
private assertDate(raw: string): DateTime {
try {
return DateTime.create(raw);
} catch (error) {
if (error instanceof InvalidDateTimeError) {
throw new BadRequestException('Invalid date');
}
throw error;
}
}
private assertStatus(raw: string): Status {
try {
return Status.create(raw);
} catch (error) {
if (error instanceof InvalidStatusError) {
throw new BadRequestException('Invalid status');
}
throw error;
}
}
}
@@ -0,0 +1,17 @@
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
export type CompanySetting = {
readonly id: string;
readonly cycleStartDate: DateTime;
readonly status: Status;
readonly createdAt: DateTime;
readonly updatedAt: DateTime;
readonly createdBy: string;
readonly updatedBy: string;
};
export type UpsertCompanySettingInput = {
readonly cycleStartDate: DateTime;
readonly userId: string;
};
@@ -0,0 +1,52 @@
import { Body, Controller, Get, Patch } from '@nestjs/common';
import {
ApiBearerAuth,
ApiForbiddenResponse,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
import { SETTINGS_PRIVILEGE_KEY } from '../shared/field-purpose';
import { CompanySettingsService } from './company-settings.service';
import {
CompanySettingDto,
UpdateCompanySettingDto,
} from './dto/company-setting.dto';
@ApiTags('settings')
@ApiBearerAuth(BEARER_AUTH_NAME)
@Controller('settings')
export class CompanySettingsController {
constructor(
private readonly companySettingsService: CompanySettingsService,
) {}
@Get()
@RequirePrivilege(SETTINGS_PRIVILEGE_KEY, 'view')
@ApiOperation({ summary: 'Get company settings' })
@ApiOkResponse({ type: CompanySettingDto })
@ApiNotFoundResponse()
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
get(): Promise<CompanySettingDto> {
return this.companySettingsService.get();
}
@Patch()
@RequirePrivilege(SETTINGS_PRIVILEGE_KEY, 'update')
@ApiOperation({ summary: 'Update company cycle start date' })
@ApiOkResponse({ type: CompanySettingDto })
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
update(
@Body() dto: UpdateCompanySettingDto,
@CurrentUser('id') userId: string,
): Promise<CompanySettingDto> {
return this.companySettingsService.update(dto.cycleStartDate, userId);
}
}
@@ -0,0 +1,68 @@
import { Inject, Injectable } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
import {
companySettings,
type CompanySettingsRow,
} from '../../../database/company-settings-table';
import type {
CompanySetting,
UpsertCompanySettingInput,
} from './company-setting';
@Injectable()
export class CompanySettingsRepository {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
async find(): Promise<CompanySetting | null> {
const rows: CompanySettingsRow[] = await this.db
.select()
.from(companySettings)
.limit(1);
const row = rows[0];
return row ? this.toDomain(row) : null;
}
async upsert(input: UpsertCompanySettingInput): Promise<CompanySetting> {
const now = DateTime.fromUnixMs(Date.now());
const existing = await this.find();
if (!existing) {
const inserted = await this.db
.insert(companySettings)
.values({
cycleStartDate: input.cycleStartDate.value,
status: Status.create(Status.DEFAULT).value,
createdAt: now.value,
updatedAt: now.value,
createdBy: input.userId,
updatedBy: input.userId,
})
.returning();
return this.toDomain(inserted[0]);
}
const updated = await this.db
.update(companySettings)
.set({
cycleStartDate: input.cycleStartDate.value,
updatedAt: now.value,
updatedBy: input.userId,
})
.where(eq(companySettings.id, existing.id))
.returning();
return this.toDomain(updated[0]);
}
private toDomain(row: CompanySettingsRow): CompanySetting {
return {
id: row.id,
cycleStartDate: DateTime.fromUnixMs(row.cycleStartDate),
status: Status.create(row.status),
createdAt: DateTime.fromUnixMs(row.createdAt),
updatedAt: DateTime.fromUnixMs(row.updatedAt),
createdBy: row.createdBy,
updatedBy: row.updatedBy,
};
}
}
@@ -0,0 +1,64 @@
import { BadRequestException, NotFoundException } 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 type { CompanySetting } from './company-setting';
import { CompanySettingsRepository } from './company-settings.repository';
import { CompanySettingsService } from './company-settings.service';
describe('CompanySettingsService', () => {
let service: CompanySettingsService;
let repository: jest.Mocked<
Pick<CompanySettingsRepository, 'find' | 'upsert'>
>;
const now = DateTime.fromUnixMs(1_700_000_000_000);
const sample: CompanySetting = {
id: 'set-1',
cycleStartDate: DateTime.create('2026-01-05'),
status: Status.create('draft'),
createdAt: now,
updatedAt: now,
createdBy: 'user-1',
updatedBy: 'user-1',
};
beforeEach(async () => {
repository = {
find: jest.fn(),
upsert: jest.fn(),
};
const moduleRef: TestingModule = await Test.createTestingModule({
providers: [
CompanySettingsService,
{ provide: CompanySettingsRepository, useValue: repository },
],
}).compile();
service = moduleRef.get(CompanySettingsService);
});
it('get throws when settings are missing', async () => {
repository.find.mockResolvedValue(null);
await expect(service.get()).rejects.toBeInstanceOf(NotFoundException);
});
it('get maps cycleStartDate to unix ms', async () => {
repository.find.mockResolvedValue(sample);
const result = await service.get();
expect(result.cycleStartDate).toBe(sample.cycleStartDate.value);
});
it('update persists start of day', async () => {
repository.upsert.mockResolvedValue(sample);
await service.update('2026-01-05', 'user-1');
const arg = repository.upsert.mock.calls[0][0];
expect(arg.cycleStartDate.equals(DateTime.create('2026-01-05'))).toBe(true);
expect(arg.userId).toBe('user-1');
});
it('update rejects invalid dates', async () => {
await expect(service.update('not-a-date', 'user-1')).rejects.toBeInstanceOf(
BadRequestException,
);
});
});
@@ -0,0 +1,67 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { CompanySettingsRepository } from './company-settings.repository';
import type { CompanySetting } from './company-setting';
@Injectable()
export class CompanySettingsService {
constructor(
private readonly companySettingsRepository: CompanySettingsRepository,
) {}
async get(): Promise<ReturnType<CompanySettingsService['toItem']>> {
const setting = await this.companySettingsRepository.find();
if (!setting) {
throw new NotFoundException('Settings not configured');
}
return this.toItem(setting);
}
async update(
cycleStartDateRaw: string,
userId: string,
): Promise<ReturnType<CompanySettingsService['toItem']>> {
const cycleStartDate = this.assertDate(cycleStartDateRaw).startOfDay();
const saved = await this.companySettingsRepository.upsert({
cycleStartDate,
userId,
});
return this.toItem(saved);
}
async requireCycleStartDate(): Promise<DateTime> {
const setting = await this.companySettingsRepository.find();
if (!setting) {
throw new NotFoundException('Settings not configured');
}
return setting.cycleStartDate;
}
toItem(setting: CompanySetting) {
return {
id: setting.id,
cycleStartDate: setting.cycleStartDate.value,
status: setting.status.value,
createdAt: setting.createdAt.value,
updatedAt: setting.updatedAt.value,
createdBy: setting.createdBy,
updatedBy: setting.updatedBy,
};
}
private assertDate(raw: string): DateTime {
try {
return DateTime.create(raw);
} catch (error) {
if (error instanceof InvalidDateTimeError) {
throw new BadRequestException('Invalid cycle start date');
}
throw error;
}
}
}
@@ -0,0 +1,35 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, Matches } from 'class-validator';
export class UpdateCompanySettingDto {
@ApiProperty({ example: '2026-01-05', description: 'YYYY-MM-DD' })
@IsString()
@IsNotEmpty()
@Matches(/^\d{4}-\d{2}-\d{2}$/, {
message: 'cycleStartDate must be a calendar date',
})
cycleStartDate!: string;
}
export class CompanySettingDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ description: 'Unix ms start of the cycle-start calendar day' })
cycleStartDate!: number;
@ApiProperty()
status!: string;
@ApiProperty()
createdAt!: number;
@ApiProperty()
updatedAt!: number;
@ApiProperty({ format: 'uuid' })
createdBy!: string;
@ApiProperty({ format: 'uuid' })
updatedBy!: string;
}
@@ -0,0 +1,37 @@
import {
isValidCycleNumber,
parseCsvRecord,
parseWeekdaysInput,
} from './field-fields';
describe('field-fields', () => {
it('accepts cycle numbers starting at 1', () => {
expect(isValidCycleNumber(1)).toBe(true);
expect(isValidCycleNumber(0)).toBe(false);
expect(isValidCycleNumber(1.5)).toBe(false);
});
it('treats omitted weekdays as days off and requires complete present days', () => {
const parsed = parseWeekdaysInput({
monday: {
startBranchId: 'b1',
endBranchId: 'b2',
customerIds: ['c1'],
},
});
expect(parsed.monday?.customerIds).toEqual(['c1']);
expect(parsed.sunday).toBeUndefined();
});
it('rejects an incomplete present weekday', () => {
expect(() =>
parseWeekdaysInput({
monday: { startBranchId: 'b1', endBranchId: 'b2', customerIds: [] },
}),
).toThrow();
});
it('parses quoted CSV cells', () => {
expect(parseCsvRecord('a,"b,c",d')).toEqual(['a', 'b,c', 'd']);
});
});
+93
View File
@@ -0,0 +1,93 @@
import type { WeekdayName } from './field-purpose';
import { isWeekdayName } from './field-purpose';
export type WeekdayTemplateInput = {
readonly startBranchId: string;
readonly endBranchId: string;
readonly customerIds: readonly string[];
};
export type WeekdaysInput = Partial<Record<WeekdayName, WeekdayTemplateInput>>;
export function isValidCycleNumber(raw: number): boolean {
return Number.isInteger(raw) && raw >= 1;
}
export function assertCompleteWeekday(
raw: WeekdayTemplateInput | undefined,
): WeekdayTemplateInput | undefined {
if (raw === undefined) {
return undefined;
}
if (
!raw.startBranchId ||
!raw.endBranchId ||
!Array.isArray(raw.customerIds) ||
raw.customerIds.length === 0
) {
throw new Error('incomplete');
}
return raw;
}
export function parseWeekdaysInput(raw: unknown): WeekdaysInput {
if (raw === undefined || raw === null) {
return {};
}
if (typeof raw !== 'object' || Array.isArray(raw)) {
throw new Error('incomplete');
}
const record = raw as Record<string, WeekdayTemplateInput>;
const result: WeekdaysInput = {};
for (const [key, value] of Object.entries(record)) {
if (!isWeekdayName(key)) {
throw new Error('incomplete');
}
const complete = assertCompleteWeekday(value);
if (complete) {
result[key] = complete;
}
}
return result;
}
/** RFC 4180-style record split that preserves commas inside quotes. */
export function parseCsvRecord(line: string): string[] {
const cells: string[] = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQuotes) {
if (ch === '"') {
if (line[i + 1] === '"') {
current += '"';
i += 1;
} else {
inQuotes = false;
}
} else {
current += ch;
}
} else if (ch === '"') {
inQuotes = true;
} else if (ch === ',') {
cells.push(current.trim());
current = '';
} else {
current += ch;
}
}
cells.push(current.trim());
return cells;
}
export function isAllowedCsvUpload(file: {
mimetype: string;
originalname: string;
}): boolean {
return (
file.mimetype.includes('csv') ||
file.originalname.toLowerCase().endsWith('.csv')
);
}
@@ -0,0 +1,19 @@
import { SetMetadata } from '@nestjs/common';
import type { PrivilegeAction } from '../../privileges/privilege-action';
import type { FieldResource } from './field-purpose';
export const REQUIRE_FIELD_PRIVILEGE_KEY = 'requireFieldPrivilege';
export type RequireFieldPrivilegeMeta = {
readonly resource: FieldResource;
readonly action: PrivilegeAction;
};
export const RequireFieldPrivilege = (
resource: FieldResource,
action: PrivilegeAction,
) =>
SetMetadata(REQUIRE_FIELD_PRIVILEGE_KEY, {
resource,
action,
} satisfies RequireFieldPrivilegeMeta);
@@ -0,0 +1,132 @@
import {
ExecutionContext,
ForbiddenException,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { AuthUser } from '../../../common/auth/auth-user';
import type { RequireFieldPrivilegeMeta } from './field-privilege.decorator';
import { REQUIRE_FIELD_PRIVILEGE_KEY } from './field-privilege.decorator';
import { FieldPrivilegeGuard } from './field-privilege.guard';
describe('FieldPrivilegeGuard', () => {
const checkPermission = jest.fn();
const getAllAndOverride = jest.fn();
const reflector = {
getAllAndOverride,
} as unknown as Reflector;
const guard = new FieldPrivilegeGuard(reflector, {
checkPermission,
} as never);
const user: AuthUser = {
id: 'user-1',
username: 'alice',
jti: 'jti-1',
isSuperadmin: false,
};
function createContext(
currentUser?: AuthUser,
body?: { purpose?: string },
query?: { purpose?: string },
): ExecutionContext {
return {
getHandler: () => jest.fn(),
getClass: () => jest.fn(),
switchToHttp: () => ({
getRequest: () => ({ user: currentUser, body, query }),
}),
} as unknown as ExecutionContext;
}
beforeEach(() => {
jest.clearAllMocks();
});
it('allows when no field privilege metadata', async () => {
getAllAndOverride.mockReturnValue(undefined);
await expect(guard.canActivate(createContext(user))).resolves.toBe(true);
expect(checkPermission).not.toHaveBeenCalled();
});
it('allows sales purpose when sales key is granted', async () => {
const meta: RequireFieldPrivilegeMeta = {
resource: 'cycle',
action: 'create',
};
getAllAndOverride.mockReturnValue(meta);
checkPermission.mockImplementation((_id: string, key: string) =>
Promise.resolve(key === 'SALES.CYCLE'),
);
await expect(
guard.canActivate(createContext(user, { purpose: 'sales' })),
).resolves.toBe(true);
});
it('forbids logistics purpose for a sales-only user', async () => {
const meta: RequireFieldPrivilegeMeta = {
resource: 'plan',
action: 'update',
};
getAllAndOverride.mockReturnValue(meta);
checkPermission.mockImplementation((_id: string, key: string) =>
Promise.resolve(key === 'SALES.PLAN'),
);
await expect(
guard.canActivate(createContext(user, { purpose: 'logistics' })),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('allows list without purpose when either sales or logistics view is granted', async () => {
const meta: RequireFieldPrivilegeMeta = {
resource: 'cycle',
action: 'view',
};
getAllAndOverride.mockReturnValue(meta);
checkPermission.mockImplementation((_id: string, key: string) =>
Promise.resolve(key === 'LOGISTICS.CYCLE'),
);
await expect(guard.canActivate(createContext(user, {}, {}))).resolves.toBe(
true,
);
});
it('skips lookup for superadmin', async () => {
const meta: RequireFieldPrivilegeMeta = {
resource: 'cycle',
action: 'delete',
};
getAllAndOverride.mockReturnValue(meta);
await expect(
guard.canActivate(createContext({ ...user, isSuperadmin: true })),
).resolves.toBe(true);
expect(checkPermission).not.toHaveBeenCalled();
});
it('unauthorized when metadata present but no user', async () => {
const meta: RequireFieldPrivilegeMeta = {
resource: 'cycle',
action: 'view',
};
getAllAndOverride.mockReturnValue(meta);
await expect(guard.canActivate(createContext())).rejects.toBeInstanceOf(
UnauthorizedException,
);
});
it('reads metadata from handler and class', async () => {
getAllAndOverride.mockReturnValue(undefined);
await guard.canActivate(createContext(user));
expect(getAllAndOverride).toHaveBeenCalledWith(
REQUIRE_FIELD_PRIVILEGE_KEY,
expect.any(Array),
);
});
});
@@ -0,0 +1,92 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { AuthUser } from '../../../common/auth/auth-user';
import type { PrivilegeAction } from '../../privileges/privilege-action';
import { PrivilegesService } from '../../privileges/privileges.service';
import {
fieldPrivilegeKey,
isFieldPurpose,
type FieldPurpose,
type FieldResource,
} from './field-purpose';
import {
REQUIRE_FIELD_PRIVILEGE_KEY,
type RequireFieldPrivilegeMeta,
} from './field-privilege.decorator';
@Injectable()
export class FieldPrivilegeGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly privilegesService: PrivilegesService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const required = this.reflector.getAllAndOverride<
RequireFieldPrivilegeMeta | undefined
>(REQUIRE_FIELD_PRIVILEGE_KEY, [context.getHandler(), context.getClass()]);
if (!required) {
return true;
}
const request = context.switchToHttp().getRequest<{
user?: AuthUser;
body?: { purpose?: string };
query?: { purpose?: string };
}>();
const user = request.user;
if (!user) {
throw new UnauthorizedException();
}
if (user.isSuperadmin) {
return true;
}
const purposeRaw = request.body?.purpose ?? request.query?.purpose;
const allowed = await this.allowedPurposes(
user.id,
required.resource,
required.action,
);
if (purposeRaw !== undefined && purposeRaw !== '') {
if (!isFieldPurpose(purposeRaw)) {
throw new ForbiddenException('Insufficient privilege');
}
if (!allowed.includes(purposeRaw)) {
throw new ForbiddenException('Insufficient privilege');
}
return true;
}
if (allowed.length === 0) {
throw new ForbiddenException('Insufficient privilege');
}
return true;
}
async allowedPurposes(
userId: string,
resource: FieldResource,
action: PrivilegeAction,
): Promise<FieldPurpose[]> {
const purposes: FieldPurpose[] = ['sales', 'logistics'];
const matches: FieldPurpose[] = [];
for (const purpose of purposes) {
const ok = await this.privilegesService.checkPermission(
userId,
fieldPrivilegeKey(resource, purpose),
action,
);
if (ok) {
matches.push(purpose);
}
}
return matches;
}
}
@@ -0,0 +1,37 @@
import {
cycleNumberForDate,
fieldPrivilegeKey,
isFieldPurpose,
isWeekdayName,
noCycleMessage,
} from './field-purpose';
describe('field purpose helpers', () => {
it('accepts sales and logistics only', () => {
expect(isFieldPurpose('sales')).toBe(true);
expect(isFieldPurpose('logistics')).toBe(true);
expect(isFieldPurpose('delivery')).toBe(false);
});
it('accepts weekday names', () => {
expect(isWeekdayName('monday')).toBe(true);
expect(isWeekdayName('Monday')).toBe(false);
});
it('maps resource and purpose to privilege keys', () => {
expect(fieldPrivilegeKey('cycle', 'sales')).toBe('SALES.CYCLE');
expect(fieldPrivilegeKey('plan', 'logistics')).toBe('LOGISTICS.PLAN');
});
it('returns purpose-specific missing-cycle messages', () => {
expect(noCycleMessage('sales')).toBe('User has no sales cycle');
expect(noCycleMessage('logistics')).toBe('User has no logistics cycle');
});
it('maps whole weeks to 1-based cycle numbers with wrap', () => {
expect(cycleNumberForDate(0, 3)).toBe(1);
expect(cycleNumberForDate(1, 3)).toBe(2);
expect(cycleNumberForDate(2, 3)).toBe(3);
expect(cycleNumberForDate(3, 3)).toBe(1);
});
});
+58
View File
@@ -0,0 +1,58 @@
export const FIELD_PURPOSES = ['sales', 'logistics'] as const;
export type FieldPurpose = (typeof FIELD_PURPOSES)[number];
export function isFieldPurpose(raw: string): raw is FieldPurpose {
return (FIELD_PURPOSES as readonly string[]).includes(raw);
}
export const WEEKDAY_NAMES = [
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
] as const;
export type WeekdayName = (typeof WEEKDAY_NAMES)[number];
export function isWeekdayName(raw: string): raw is WeekdayName {
return (WEEKDAY_NAMES as readonly string[]).includes(raw);
}
export const SALES_CYCLE_PRIVILEGE_KEY = 'SALES.CYCLE';
export const SALES_PLAN_PRIVILEGE_KEY = 'SALES.PLAN';
export const LOGISTICS_CYCLE_PRIVILEGE_KEY = 'LOGISTICS.CYCLE';
export const LOGISTICS_PLAN_PRIVILEGE_KEY = 'LOGISTICS.PLAN';
export const SETTINGS_PRIVILEGE_KEY = 'CONFIGURATION.SETTING';
export type FieldResource = 'cycle' | 'plan';
export function fieldPrivilegeKey(
resource: FieldResource,
purpose: FieldPurpose,
): string {
if (resource === 'cycle') {
return purpose === 'sales'
? SALES_CYCLE_PRIVILEGE_KEY
: LOGISTICS_CYCLE_PRIVILEGE_KEY;
}
return purpose === 'sales'
? SALES_PLAN_PRIVILEGE_KEY
: LOGISTICS_PLAN_PRIVILEGE_KEY;
}
export function noCycleMessage(purpose: FieldPurpose): string {
return purpose === 'sales'
? 'User has no sales cycle'
: 'User has no logistics cycle';
}
export function cycleNumberForDate(
wholeWeeksSinceEpoch: number,
totalCycles: number,
): number {
return (wholeWeeksSinceEpoch % totalCycles) + 1;
}
@@ -0,0 +1,21 @@
import { ApiProperty } from '@nestjs/swagger';
export class RouteGeometryDto {
@ApiProperty({ example: 'LineString' })
type!: string;
@ApiProperty({
type: 'array',
items: {
type: 'array',
items: { type: 'number' },
minItems: 2,
maxItems: 2,
},
example: [
[106.8456, -6.2088],
[107.0, -6.3],
],
})
coordinates!: ReadonlyArray<readonly number[]>;
}
@@ -0,0 +1,47 @@
import {
buildRouteLineString,
isUsableRouteGeometry,
} from './route-line-string';
describe('buildRouteLineString', () => {
it('preserves given order and skips unlocated points', () => {
const geometry = buildRouteLineString([
{ longitude: 106.8, latitude: -6.2 },
{ longitude: null, latitude: -6.3 },
{ longitude: 106.9, latitude: null },
{ longitude: 107.0, latitude: -6.4 },
{ longitude: 107.1, latitude: -6.5 },
]);
expect(geometry).toEqual({
type: 'LineString',
coordinates: [
[106.8, -6.2],
[107.0, -6.4],
[107.1, -6.5],
],
});
});
it('returns two coordinates when only start and end locate', () => {
const geometry = buildRouteLineString([
{ longitude: 106.8, latitude: -6.2 },
{ longitude: null, latitude: null },
{ longitude: 107.0, latitude: -6.4 },
]);
expect(geometry.coordinates).toEqual([
[106.8, -6.2],
[107.0, -6.4],
]);
expect(isUsableRouteGeometry(geometry)).toBe(true);
});
it('is not usable with fewer than two located points', () => {
const none = buildRouteLineString([{ longitude: null, latitude: null }]);
const one = buildRouteLineString([{ longitude: 106.8, latitude: -6.2 }]);
expect(isUsableRouteGeometry(none)).toBe(false);
expect(isUsableRouteGeometry(one)).toBe(false);
});
});
@@ -0,0 +1,44 @@
export type GeoPoint = {
readonly longitude: number | null;
readonly latitude: number | null;
};
export type RouteGeometry = {
readonly type: 'LineString';
readonly coordinates: readonly (readonly [number, number])[];
};
export function isLocatedPoint(
point: GeoPoint,
): point is { readonly longitude: number; readonly latitude: number } {
return (
typeof point.longitude === 'number' &&
Number.isFinite(point.longitude) &&
typeof point.latitude === 'number' &&
Number.isFinite(point.latitude)
);
}
/**
* Builds a LineString from points in the given order, skipping unlocated ones.
* Coordinates are [longitude, latitude].
*/
export function buildRouteLineString(
points: readonly GeoPoint[],
): RouteGeometry {
const coordinates: Array<readonly [number, number]> = [];
for (const point of points) {
if (!isLocatedPoint(point)) {
continue;
}
coordinates.push([point.longitude, point.latitude]);
}
return {
type: 'LineString',
coordinates,
};
}
export function isUsableRouteGeometry(geometry: RouteGeometry): boolean {
return geometry.coordinates.length >= 2;
}
+120
View File
@@ -0,0 +1,120 @@
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 { 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';
describe('Company settings (e2e)', () => {
let app: INestApplication<App>;
let db: DrizzleDB;
const password = 'password123';
const adminUsername = `set_admin_${Date.now()}`;
const otherUsername = `set_other_${Date.now()}`;
let adminAccessToken: string;
let adminUserId: string;
let otherAccessToken: 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 adminReg = await request(app.getHttpServer())
.post('/auth/register')
.send({ username: adminUsername, password })
.expect(201);
adminAccessToken = (adminReg.body as { accessToken: string }).accessToken;
const adminMe = await request(app.getHttpServer())
.get('/auth/me')
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(200);
adminUserId = (adminMe.body as { id: string }).id;
const otherReg = await request(app.getHttpServer())
.post('/auth/register')
.send({ username: otherUsername, password })
.expect(201);
otherAccessToken = (otherReg.body as { accessToken: string }).accessToken;
const now = Date.now();
const [priv] = await db
.insert(privileges)
.values({
name: 'Settings Admin',
code: `SET_ADMIN_${now}`,
status: 'active',
createdAt: now,
updatedAt: now,
createdBy: adminUserId,
updatedBy: adminUserId,
})
.returning();
const keys = await db.select().from(privilegeKeys);
const detailRows = keys.flatMap((key) =>
PRIVILEGE_ACTIONS.map((action) => ({
privilegeId: priv.id,
privilegeKeyId: key.id,
action,
value: true,
})),
);
await db.insert(privilegeDetails).values(detailRows);
await db
.update(users)
.set({ privilegeId: priv.id, updatedAt: Date.now() })
.where(eq(users.id, adminUserId));
});
afterAll(async () => {
await app.close();
});
it('forbids settings without permission', async () => {
await request(app.getHttpServer())
.get('/settings')
.set('Authorization', `Bearer ${otherAccessToken}`)
.expect(403);
});
it('upserts the cycle start date', async () => {
const updated = await request(app.getHttpServer())
.patch('/settings')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({ cycleStartDate: '2026-01-05' })
.expect(200);
expect((updated.body as { cycleStartDate: number }).cycleStartDate).toBe(
Date.parse('2026-01-04T17:00:00.000Z'),
);
const got = await request(app.getHttpServer())
.get('/settings')
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(200);
expect((got.body as { cycleStartDate: number }).cycleStartDate).toBe(
(updated.body as { cycleStartDate: number }).cycleStartDate,
);
});
});
+238
View File
@@ -0,0 +1,238 @@
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 { 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';
describe('Cycles (e2e)', () => {
let app: INestApplication<App>;
let db: DrizzleDB;
const password = 'password123';
const adminUsername = `cyc_admin_${Date.now()}`;
const otherUsername = `cyc_other_${Date.now()}`;
let adminAccessToken: string;
let adminUserId: string;
let otherAccessToken: string;
let employeeId: string;
let startBranchId: string;
let endBranchId: string;
let customerId: 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 adminReg = await request(app.getHttpServer())
.post('/auth/register')
.send({ username: adminUsername, password })
.expect(201);
adminAccessToken = (adminReg.body as { accessToken: string }).accessToken;
const adminMe = await request(app.getHttpServer())
.get('/auth/me')
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(200);
adminUserId = (adminMe.body as { id: string }).id;
const otherReg = await request(app.getHttpServer())
.post('/auth/register')
.send({ username: otherUsername, password })
.expect(201);
otherAccessToken = (otherReg.body as { accessToken: string }).accessToken;
const now = Date.now();
const [priv] = await db
.insert(privileges)
.values({
name: 'Cycle Admin',
code: `CYC_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 suffix = Date.now().toString().slice(-6);
const employee = await request(app.getHttpServer())
.post('/employees')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({
code: `CYC_E_${suffix}`,
name: 'Ada Lovelace',
phone: '+6281234567890',
position: 'sales',
})
.expect(201);
employeeId = (employee.body as { id: string }).id;
const start = await request(app.getHttpServer())
.post('/branches')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({
code: `CYC_S_${suffix}`,
name: 'Start Branch',
phone: '+6281234567890',
address: 'Jl Sudirman No 1',
latitude: -6.2,
longitude: 106.8,
workingDaysStart: 'monday',
workingDaysEnd: 'friday',
workingHoursStart: '08:00',
workingHoursEnd: '17:00',
})
.expect(201);
startBranchId = (start.body as { id: string }).id;
const end = await request(app.getHttpServer())
.post('/branches')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({
code: `CYC_E_${suffix}`,
name: 'End Branch',
phone: '+6281234567891',
address: 'Jl Thamrin No 2',
latitude: -6.21,
longitude: 106.81,
workingDaysStart: 'monday',
workingDaysEnd: 'friday',
workingHoursStart: '08:00',
workingHoursEnd: '17:00',
})
.expect(201);
endBranchId = (end.body as { id: string }).id;
const customer = await request(app.getHttpServer())
.post('/customers')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({
code: `CYC_C_${suffix}`,
name: 'Acme Corp',
phone: '+6281234567892',
address: 'Jl Gatot No 3',
latitude: -6.22,
longitude: 106.82,
})
.expect(201);
customerId = (customer.body as { id: string }).id;
});
afterAll(async () => {
await app.close();
});
it('forbids cycles list without permission', async () => {
await request(app.getHttpServer())
.get('/cycles')
.set('Authorization', `Bearer ${otherAccessToken}`)
.expect(403);
});
it('creates a sales cycle, rejects an incomplete day, and archives on delete', async () => {
await request(app.getHttpServer())
.post('/cycles')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({
employeeId,
purpose: 'sales',
cycleNumber: 1,
weekdays: {
monday: {
startBranchId,
endBranchId,
customerIds: [],
},
},
})
.expect(400);
const created = await request(app.getHttpServer())
.post('/cycles')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({
employeeId,
purpose: 'sales',
cycleNumber: 1,
weekdays: {
monday: {
startBranchId,
endBranchId,
customerIds: [customerId],
},
},
})
.expect(201);
const id = (created.body as { id: string }).id;
expect((created.body as { purpose: string }).purpose).toBe('sales');
expect(
(created.body as { weekdays: Array<{ weekday: string }> }).weekdays,
).toHaveLength(1);
await request(app.getHttpServer())
.post('/cycles')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({
employeeId,
purpose: 'sales',
cycleNumber: 1,
weekdays: {
tuesday: {
startBranchId,
endBranchId,
customerIds: [customerId],
},
},
})
.expect(409);
await request(app.getHttpServer())
.delete(`/cycles/${id}`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(204);
const detail = await request(app.getHttpServer())
.get(`/cycles/${id}`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(200);
expect((detail.body as { status: string }).status).toBe('archived');
});
});
+276
View File
@@ -0,0 +1,276 @@
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 { 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';
describe('Plans (e2e)', () => {
let app: INestApplication<App>;
let db: DrizzleDB;
const password = 'password123';
const adminUsername = `pln_admin_${Date.now()}`;
const otherUsername = `pln_other_${Date.now()}`;
let adminAccessToken: string;
let adminUserId: string;
let otherAccessToken: string;
let employeeId: string;
let startBranchId: string;
let endBranchId: string;
let customerId: string;
let extraCustomerId: string;
let cycleId: 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 adminReg = await request(app.getHttpServer())
.post('/auth/register')
.send({ username: adminUsername, password })
.expect(201);
adminAccessToken = (adminReg.body as { accessToken: string }).accessToken;
const adminMe = await request(app.getHttpServer())
.get('/auth/me')
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(200);
adminUserId = (adminMe.body as { id: string }).id;
const otherReg = await request(app.getHttpServer())
.post('/auth/register')
.send({ username: otherUsername, password })
.expect(201);
otherAccessToken = (otherReg.body as { accessToken: string }).accessToken;
const now = Date.now();
const [priv] = await db
.insert(privileges)
.values({
name: 'Plan Admin',
code: `PLN_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 suffix = Date.now().toString().slice(-6);
const employee = await request(app.getHttpServer())
.post('/employees')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({
code: `PLN_E_${suffix}`,
name: 'Ada Lovelace',
phone: '+6281234567890',
position: 'sales',
})
.expect(201);
employeeId = (employee.body as { id: string }).id;
const start = await request(app.getHttpServer())
.post('/branches')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({
code: `PLN_S_${suffix}`,
name: 'Start Branch',
phone: '+6281234567890',
address: 'Jl Sudirman No 1',
latitude: -6.2,
longitude: 106.8,
workingDaysStart: 'monday',
workingDaysEnd: 'friday',
workingHoursStart: '08:00',
workingHoursEnd: '17:00',
})
.expect(201);
startBranchId = (start.body as { id: string }).id;
const end = await request(app.getHttpServer())
.post('/branches')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({
code: `PLN_N_${suffix}`,
name: 'End Branch',
phone: '+6281234567891',
address: 'Jl Thamrin No 2',
latitude: -6.21,
longitude: 106.81,
workingDaysStart: 'monday',
workingDaysEnd: 'friday',
workingHoursStart: '08:00',
workingHoursEnd: '17:00',
})
.expect(201);
endBranchId = (end.body as { id: string }).id;
const customer = await request(app.getHttpServer())
.post('/customers')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({
code: `PLN_C_${suffix}`,
name: 'Acme Corp',
phone: '+6281234567892',
address: 'Jl Gatot No 3',
latitude: -6.22,
longitude: 106.82,
})
.expect(201);
customerId = (customer.body as { id: string }).id;
const extra = await request(app.getHttpServer())
.post('/customers')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({
code: `PLN_D_${suffix}`,
name: 'Beta Corp',
phone: '+6281234567893',
address: 'Jl Rasuna No 4',
latitude: -6.23,
longitude: 106.83,
})
.expect(201);
extraCustomerId = (extra.body as { id: string }).id;
await request(app.getHttpServer())
.patch('/settings')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({ cycleStartDate: '2026-01-05' })
.expect(200);
const cycle = await request(app.getHttpServer())
.post('/cycles')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({
employeeId,
purpose: 'sales',
cycleNumber: 1,
weekdays: {
monday: {
startBranchId,
endBranchId,
customerIds: [customerId],
},
},
})
.expect(201);
cycleId = (cycle.body as { id: string }).id;
await request(app.getHttpServer())
.patch(`/cycles/${cycleId}/status`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({ status: 'active' })
.expect(200);
});
afterAll(async () => {
await app.close();
});
it('forbids plans list without permission', async () => {
await request(app.getHttpServer())
.get('/plans')
.set('Authorization', `Bearer ${otherAccessToken}`)
.expect(403);
});
it('generates Monday plans, skips existing, and supports D-Day destination edits', async () => {
const generated = await request(app.getHttpServer())
.post('/plans/generate')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({
employeeId,
purpose: 'sales',
from: '2026-01-05',
to: '2026-01-12',
})
.expect(201);
expect((generated.body as { created: number }).created).toBe(2);
expect((generated.body as { skipped: number }).skipped).toBeGreaterThan(0);
const again = await request(app.getHttpServer())
.post('/plans/generate')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({
employeeId,
purpose: 'sales',
from: '2026-01-05',
to: '2026-01-12',
})
.expect(201);
expect((again.body as { created: number }).created).toBe(0);
const list = await request(app.getHttpServer())
.get('/plans')
.query({ employeeId, purpose: 'sales', date: '2026-01-05' })
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(200);
const planId = (list.body.data as Array<{ id: string }>)[0].id;
const added = await request(app.getHttpServer())
.post(`/plans/${planId}/destinations`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({ customerId: extraCustomerId })
.expect(201);
expect(
(added.body as { destinations: Array<{ customerId: string }> })
.destinations,
).toHaveLength(2);
const extraDest = (
added.body as { destinations: Array<{ id: string; customerId: string }> }
).destinations.find((d) => d.customerId === extraCustomerId);
const removed = await request(app.getHttpServer())
.delete(`/plans/${planId}/destinations/${extraDest?.id}`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(200);
expect(
(removed.body as { destinations: unknown[] }).destinations,
).toHaveLength(1);
const lastId = (removed.body as { destinations: Array<{ id: string }> })
.destinations[0].id;
await request(app.getHttpServer())
.delete(`/plans/${planId}/destinations/${lastId}`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(400);
});
});