From d28506e878f1ef10bb3032adfd0e131c0e9c28da Mon Sep 17 00:00:00 2001 From: shancheas Date: Mon, 24 Aug 2026 13:42:19 +0700 Subject: [PATCH] Add branches management module with database schema and validation - Introduced `BranchesModule` to manage organizational branches, including read and write controllers. - Created database migrations for the `branches` table, including constraints and unique indexes. - Implemented validation for branch fields such as name, code, and address with corresponding utility functions. - Developed service and repository layers for handling branch data operations. - Added unit tests for the branches service, repository, and controllers to ensure functionality and correctness. - Updated application module to include the new `BranchesModule` for better organization. --- drizzle/migrations/0005_past_vengeance.sql | 29 + drizzle/migrations/meta/0005_snapshot.json | 813 ++++++++++++++++++ drizzle/migrations/meta/_journal.json | 7 + src/database/branches-table.ts | 43 + src/database/schema.ts | 2 + .../branches/branch-fields.spec.ts | 184 ++++ .../configuration/branches/branch-fields.ts | 123 +++ src/modules/configuration/branches/branch.ts | 74 ++ .../branches/branches-read.controller.spec.ts | 34 + .../branches/branches-read.controller.ts | 64 ++ .../branches-write.controller.spec.ts | 83 ++ .../branches/branches-write.controller.ts | 178 ++++ .../configuration/branches/branches.module.ts | 12 + .../branches/branches.repository.spec.ts | 269 ++++++ .../branches/branches.repository.ts | 339 ++++++++ .../branches/branches.service.spec.ts | 324 +++++++ .../branches/branches.service.ts | 487 +++++++++++ .../configuration/branches/dto/branch.dto.ts | 340 ++++++++ .../configuration/configuration.module.ts | 5 +- .../divisions/divisions.repository.spec.ts | 7 + .../divisions/divisions.repository.ts | 38 +- test/branches.e2e-spec.ts | 244 ++++++ 22 files changed, 3686 insertions(+), 13 deletions(-) create mode 100644 drizzle/migrations/0005_past_vengeance.sql create mode 100644 drizzle/migrations/meta/0005_snapshot.json create mode 100644 src/database/branches-table.ts create mode 100644 src/modules/configuration/branches/branch-fields.spec.ts create mode 100644 src/modules/configuration/branches/branch-fields.ts create mode 100644 src/modules/configuration/branches/branch.ts create mode 100644 src/modules/configuration/branches/branches-read.controller.spec.ts create mode 100644 src/modules/configuration/branches/branches-read.controller.ts create mode 100644 src/modules/configuration/branches/branches-write.controller.spec.ts create mode 100644 src/modules/configuration/branches/branches-write.controller.ts create mode 100644 src/modules/configuration/branches/branches.module.ts create mode 100644 src/modules/configuration/branches/branches.repository.spec.ts create mode 100644 src/modules/configuration/branches/branches.repository.ts create mode 100644 src/modules/configuration/branches/branches.service.spec.ts create mode 100644 src/modules/configuration/branches/branches.service.ts create mode 100644 src/modules/configuration/branches/dto/branch.dto.ts create mode 100644 test/branches.e2e-spec.ts diff --git a/drizzle/migrations/0005_past_vengeance.sql b/drizzle/migrations/0005_past_vengeance.sql new file mode 100644 index 0000000..cd0a9bc --- /dev/null +++ b/drizzle/migrations/0005_past_vengeance.sql @@ -0,0 +1,29 @@ +CREATE TABLE "branches" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "code" varchar(16) NOT NULL, + "name" varchar(64) NOT NULL, + "phone" text NOT NULL, + "address" text NOT NULL, + "latitude" double precision, + "longitude" double precision, + "working_days_start" text NOT NULL, + "working_days_end" text NOT NULL, + "working_hours_start" varchar(5) NOT NULL, + "working_hours_end" varchar(5) NOT NULL, + "nfc_id" text, + "division_id" uuid, + "status" text DEFAULT 'draft' NOT NULL, + "created_at" bigint NOT NULL, + "updated_at" bigint NOT NULL, + "created_by" uuid NOT NULL, + "updated_by" uuid NOT NULL +); +--> statement-breakpoint +ALTER TABLE "branches" ADD CONSTRAINT "branches_division_id_divisions_id_fk" FOREIGN KEY ("division_id") REFERENCES "public"."divisions"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "branches" ADD CONSTRAINT "branches_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 "branches" ADD CONSTRAINT "branches_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "branches_code_unique" ON "branches" USING btree ("code");--> statement-breakpoint +CREATE UNIQUE INDEX "branches_nfc_id_unique" ON "branches" USING btree ("nfc_id"); +--> statement-breakpoint +INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES + ('CONFIGURATION.BRANCH', 'Branches', 4); \ No newline at end of file diff --git a/drizzle/migrations/meta/0005_snapshot.json b/drizzle/migrations/meta/0005_snapshot.json new file mode 100644 index 0000000..e757c8c --- /dev/null +++ b/drizzle/migrations/meta/0005_snapshot.json @@ -0,0 +1,813 @@ +{ + "id": "b6dfe327-def4-4502-a845-9f6a0df47065", + "prevId": "08391e1e-5713-4662-8622-818ec5ab33e7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.branches": { + "name": "branches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "code": { + "name": "code", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "working_days_start": { + "name": "working_days_start", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "working_days_end": { + "name": "working_days_end", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "working_hours_start": { + "name": "working_hours_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "working_hours_end": { + "name": "working_hours_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "nfc_id": { + "name": "nfc_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "division_id": { + "name": "division_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "branches_code_unique": { + "name": "branches_code_unique", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branches_nfc_id_unique": { + "name": "branches_nfc_id_unique", + "columns": [ + { + "expression": "nfc_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "branches_division_id_divisions_id_fk": { + "name": "branches_division_id_divisions_id_fk", + "tableFrom": "branches", + "tableTo": "divisions", + "columnsFrom": [ + "division_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "branches_created_by_users_id_fk": { + "name": "branches_created_by_users_id_fk", + "tableFrom": "branches", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "branches_updated_by_users_id_fk": { + "name": "branches_updated_by_users_id_fk", + "tableFrom": "branches", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.divisions": { + "name": "divisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "divisions_code_unique": { + "name": "divisions_code_unique", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "divisions_created_by_users_id_fk": { + "name": "divisions_created_by_users_id_fk", + "tableFrom": "divisions", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "divisions_updated_by_users_id_fk": { + "name": "divisions_updated_by_users_id_fk", + "tableFrom": "divisions", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.privilege_details": { + "name": "privilege_details", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "privilege_id": { + "name": "privilege_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "privilege_key_id": { + "name": "privilege_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "privilege_details_privilege_key_action_unique": { + "name": "privilege_details_privilege_key_action_unique", + "columns": [ + { + "expression": "privilege_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "privilege_key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "privilege_details_privilege_id_idx": { + "name": "privilege_details_privilege_id_idx", + "columns": [ + { + "expression": "privilege_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "privilege_details_privilege_id_privileges_id_fk": { + "name": "privilege_details_privilege_id_privileges_id_fk", + "tableFrom": "privilege_details", + "tableTo": "privileges", + "columnsFrom": [ + "privilege_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "privilege_details_privilege_key_id_privilege_keys_id_fk": { + "name": "privilege_details_privilege_key_id_privilege_keys_id_fk", + "tableFrom": "privilege_details", + "tableTo": "privilege_keys", + "columnsFrom": [ + "privilege_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.privilege_keys": { + "name": "privilege_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "privilege_keys_code_unique": { + "name": "privilege_keys_code_unique", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.privileges": { + "name": "privileges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "privileges_code_unique": { + "name": "privileges_code_unique", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "privileges_created_by_users_id_fk": { + "name": "privileges_created_by_users_id_fk", + "tableFrom": "privileges", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "privileges_updated_by_users_id_fk": { + "name": "privileges_updated_by_users_id_fk", + "tableFrom": "privileges", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_jti": { + "name": "access_jti", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "replaced_by": { + "name": "replaced_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "refresh_tokens_token_hash_unique": { + "name": "refresh_tokens_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_tokens_user_id_idx": { + "name": "refresh_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revoked_access_tokens": { + "name": "revoked_access_tokens", + "schema": "", + "columns": { + "jti": { + "name": "jti", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "privilege_id": { + "name": "privilege_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_superadmin": { + "name": "is_superadmin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_privilege_id_idx": { + "name": "users_privilege_id_idx", + "columns": [ + { + "expression": "privilege_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index 60ddc34..3ce35ff 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1787546862278, "tag": "0004_next_the_fury", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1787549883658, + "tag": "0005_past_vengeance", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/database/branches-table.ts b/src/database/branches-table.ts new file mode 100644 index 0000000..2760f1a --- /dev/null +++ b/src/database/branches-table.ts @@ -0,0 +1,43 @@ +import { + doublePrecision, + pgTable, + text, + uniqueIndex, + uuid, + varchar, +} from 'drizzle-orm/pg-core'; +import { primaryEntityColumns } from './primary-entity-columns'; +import { divisions, users } from './schema'; + +/** + * Organizational branches (primary aggregate). + * Kept in a separate module so Drizzle's table type stays resolvable. + */ +export const branches = pgTable( + 'branches', + { + id: uuid('id').defaultRandom().notNull().primaryKey(), + code: varchar('code', { length: 16 }).notNull(), + name: varchar('name', { length: 64 }).notNull(), + phone: text('phone').notNull(), + address: text('address').notNull(), + latitude: doublePrecision('latitude'), + longitude: doublePrecision('longitude'), + workingDaysStart: text('working_days_start').notNull(), + workingDaysEnd: text('working_days_end').notNull(), + workingHoursStart: varchar('working_hours_start', { length: 5 }).notNull(), + workingHoursEnd: varchar('working_hours_end', { length: 5 }).notNull(), + nfcId: text('nfc_id'), + divisionId: uuid('division_id').references(() => divisions.id, { + onDelete: 'restrict', + }), + ...primaryEntityColumns(users), + }, + (t) => [ + uniqueIndex('branches_code_unique').on(t.code), + uniqueIndex('branches_nfc_id_unique').on(t.nfcId), + ], +); + +export type BranchRow = typeof branches.$inferSelect; +export type NewBranchRow = typeof branches.$inferInsert; diff --git a/src/database/schema.ts b/src/database/schema.ts index 51c2cc2..82f1609 100644 --- a/src/database/schema.ts +++ b/src/database/schema.ts @@ -141,3 +141,5 @@ export type PrivilegeRow = typeof privileges.$inferSelect; export type PrivilegeDetailRow = typeof privilegeDetails.$inferSelect; export type DivisionRow = typeof divisions.$inferSelect; export type NewDivisionRow = typeof divisions.$inferInsert; + +export { branches, type BranchRow, type NewBranchRow } from './branches-table'; diff --git a/src/modules/configuration/branches/branch-fields.spec.ts b/src/modules/configuration/branches/branch-fields.spec.ts new file mode 100644 index 0000000..a44243e --- /dev/null +++ b/src/modules/configuration/branches/branch-fields.spec.ts @@ -0,0 +1,184 @@ +import { + BRANCH_CODE_MAX_LENGTH, + BRANCH_NAME_MAX_LENGTH, + isAllowedCsvUpload, + isValidBranchAddress, + isValidBranchCode, + isValidBranchName, + isValidDivisionId, + isValidLatitude, + isValidLongitude, + isValidNfcId, + isValidWeekday, + isValidWorkingHours, + parseCsvRecord, +} from './branch-fields'; + +describe('branch fields', () => { + describe('isValidBranchName', () => { + it.each(['Jakarta', 'South Jakarta', 'A', 'North West Region'])( + 'accepts %s', + (name) => { + expect(isValidBranchName(name)).toBe(true); + }, + ); + + it.each([ + '', + 'Jakarta1', + 'South-Jakarta', + 'JKT_01', + ' Jakarta', + 'Jakarta ', + 'South Jakarta', + ])('rejects %s', (name) => { + expect(isValidBranchName(name)).toBe(false); + }); + + it('rejects names longer than 64 characters', () => { + expect(isValidBranchName('A'.repeat(BRANCH_NAME_MAX_LENGTH + 1))).toBe( + false, + ); + expect(isValidBranchName('A'.repeat(BRANCH_NAME_MAX_LENGTH))).toBe(true); + }); + }); + + describe('isValidBranchCode', () => { + it.each(['JKT', 'JKT_01', 'A', 'ops2', 'A_b_1'])('accepts %s', (code) => { + expect(isValidBranchCode(code)).toBe(true); + }); + + it.each(['', 'JKT 01', 'JKT-01', 'JKT.01', ' JKT', 'JKT '])( + 'rejects %s', + (code) => { + expect(isValidBranchCode(code)).toBe(false); + }, + ); + + it('rejects codes longer than 16 characters', () => { + expect(isValidBranchCode('A'.repeat(BRANCH_CODE_MAX_LENGTH + 1))).toBe( + false, + ); + expect(isValidBranchCode('A'.repeat(BRANCH_CODE_MAX_LENGTH))).toBe(true); + }); + }); + + describe('isValidBranchAddress', () => { + it('accepts a non-empty address', () => { + expect(isValidBranchAddress('Jl Sudirman No 1')).toBe(true); + }); + + it('rejects empty or oversized addresses', () => { + expect(isValidBranchAddress('')).toBe(false); + expect(isValidBranchAddress('A'.repeat(256))).toBe(false); + }); + }); + + describe('isValidWeekday', () => { + it.each([ + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sunday', + ])('accepts %s', (day) => { + expect(isValidWeekday(day)).toBe(true); + }); + + it.each(['Monday', 'mon', '', 'friday '])('rejects %s', (day) => { + expect(isValidWeekday(day)).toBe(false); + }); + }); + + describe('isValidWorkingHours', () => { + it.each(['00:00', '08:00', '17:30', '23:59'])('accepts %s', (hours) => { + expect(isValidWorkingHours(hours)).toBe(true); + }); + + it.each(['8:00', '24:00', '17:60', '0800', '17:3', ''])( + 'rejects %s', + (hours) => { + expect(isValidWorkingHours(hours)).toBe(false); + }, + ); + }); + + describe('coordinates', () => { + it('accepts latitude and longitude in range', () => { + expect(isValidLatitude(-90)).toBe(true); + expect(isValidLatitude(90)).toBe(true); + expect(isValidLongitude(-180)).toBe(true); + expect(isValidLongitude(180)).toBe(true); + }); + + it('rejects out of range coordinates', () => { + expect(isValidLatitude(-90.1)).toBe(false); + expect(isValidLatitude(90.1)).toBe(false); + expect(isValidLongitude(-180.1)).toBe(false); + expect(isValidLongitude(180.1)).toBe(false); + expect(isValidLatitude(Number.NaN)).toBe(false); + }); + }); + + describe('isValidNfcId', () => { + it('accepts a non-empty NFC id', () => { + expect(isValidNfcId('NFC-001')).toBe(true); + }); + + it('rejects empty NFC id', () => { + expect(isValidNfcId('')).toBe(false); + }); + }); + + describe('isValidDivisionId', () => { + it('accepts a UUID v4', () => { + expect(isValidDivisionId('550e8400-e29b-41d4-a716-446655440000')).toBe( + true, + ); + }); + + it('rejects a non-UUID', () => { + expect(isValidDivisionId('not-a-uuid')).toBe(false); + }); + }); + + describe('parseCsvRecord', () => { + it('keeps commas inside quoted fields', () => { + expect( + parseCsvRecord('JKT_01,Jakarta Pusat,"Jl Sudirman No 1, Blok A"'), + ).toEqual(['JKT_01', 'Jakarta Pusat', 'Jl Sudirman No 1, Blok A']); + }); + + it('unescapes doubled quotes', () => { + expect(parseCsvRecord('"Say ""hello""",x')).toEqual(['Say "hello"', 'x']); + }); + }); + + describe('isAllowedCsvUpload', () => { + it('accepts csv mime or .csv names', () => { + expect( + isAllowedCsvUpload({ + mimetype: 'text/csv', + originalname: 'x.txt', + }), + ).toBe(true); + expect( + isAllowedCsvUpload({ + mimetype: 'application/octet-stream', + originalname: 'branches.csv', + }), + ).toBe(true); + }); + + it('rejects non-csv files', () => { + expect( + isAllowedCsvUpload({ + mimetype: 'application/pdf', + originalname: 'x.pdf', + }), + ).toBe(false); + }); + }); +}); diff --git a/src/modules/configuration/branches/branch-fields.ts b/src/modules/configuration/branches/branch-fields.ts new file mode 100644 index 0000000..04baf98 --- /dev/null +++ b/src/modules/configuration/branches/branch-fields.ts @@ -0,0 +1,123 @@ +export const BRANCH_NAME_MAX_LENGTH = 64; +export const BRANCH_CODE_MAX_LENGTH = 16; +export const BRANCH_ADDRESS_MAX_LENGTH = 255; +export const BRANCH_NFC_ID_MAX_LENGTH = 64; + +/** Letters with single spaces between words. */ +export const BRANCH_NAME_PATTERN = /^[A-Za-z]+(?: [A-Za-z]+)*$/; + +/** Alphanumeric and underscore; no spaces. */ +export const BRANCH_CODE_PATTERN = /^[A-Za-z0-9_]+$/; + +export const WEEKDAYS = [ + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sunday', +] as const; + +export type Weekday = (typeof WEEKDAYS)[number]; + +/** 24-hour clock HH:mm. */ +export const WORKING_HOURS_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/; + +export function isValidBranchName(raw: string): boolean { + return ( + typeof raw === 'string' && + raw.length > 0 && + raw.length <= BRANCH_NAME_MAX_LENGTH && + BRANCH_NAME_PATTERN.test(raw) + ); +} + +export function isValidBranchCode(raw: string): boolean { + return ( + typeof raw === 'string' && + raw.length > 0 && + raw.length <= BRANCH_CODE_MAX_LENGTH && + BRANCH_CODE_PATTERN.test(raw) + ); +} + +export function isValidBranchAddress(raw: string): boolean { + return ( + typeof raw === 'string' && + raw.length > 0 && + raw.length <= BRANCH_ADDRESS_MAX_LENGTH + ); +} + +export function isValidWeekday(raw: string): raw is Weekday { + return (WEEKDAYS as readonly string[]).includes(raw); +} + +export function isValidWorkingHours(raw: string): boolean { + return typeof raw === 'string' && WORKING_HOURS_PATTERN.test(raw); +} + +export function isValidLatitude(raw: number): boolean { + return Number.isFinite(raw) && raw >= -90 && raw <= 90; +} + +export function isValidLongitude(raw: number): boolean { + return Number.isFinite(raw) && raw >= -180 && raw <= 180; +} + +export function isValidNfcId(raw: string): boolean { + return ( + typeof raw === 'string' && + raw.length > 0 && + raw.length <= BRANCH_NFC_ID_MAX_LENGTH + ); +} + +export const UUID_V4_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export function isValidDivisionId(raw: string): boolean { + return typeof raw === 'string' && UUID_V4_PATTERN.test(raw); +} + +/** 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') + ); +} diff --git a/src/modules/configuration/branches/branch.ts b/src/modules/configuration/branches/branch.ts new file mode 100644 index 0000000..7faf99e --- /dev/null +++ b/src/modules/configuration/branches/branch.ts @@ -0,0 +1,74 @@ +import { DateTime } from '../../../common/value-objects/date-time/date-time'; +import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number'; +import { Status } from '../../../common/value-objects/status/status'; + +export type Branch = { + readonly id: string; + readonly code: string; + readonly name: string; + readonly phone: PhoneNumber; + readonly address: string; + readonly latitude: number | null; + readonly longitude: number | null; + readonly workingDaysStart: string; + readonly workingDaysEnd: string; + readonly workingHoursStart: string; + readonly workingHoursEnd: string; + readonly nfcId: string | null; + readonly divisionId: string | null; + readonly status: Status; + readonly createdAt: DateTime; + readonly updatedAt: DateTime; + readonly createdBy: string; + readonly updatedBy: string; +}; + +export type CreateBranchInput = { + readonly code: string; + readonly name: string; + readonly phone: PhoneNumber; + readonly address: string; + readonly latitude?: number | null; + readonly longitude?: number | null; + readonly workingDaysStart: string; + readonly workingDaysEnd: string; + readonly workingHoursStart: string; + readonly workingHoursEnd: string; + readonly nfcId?: string | null; + readonly divisionId?: string | null; + readonly status?: Status; + readonly userId: string; +}; + +export type UpdateBranchInput = { + readonly code?: string; + readonly name?: string; + readonly phone?: PhoneNumber; + readonly address?: string; + readonly latitude?: number | null; + readonly longitude?: number | null; + readonly workingDaysStart?: string; + readonly workingDaysEnd?: string; + readonly workingHoursStart?: string; + readonly workingHoursEnd?: string; + readonly nfcId?: string | null; + readonly divisionId?: string | null; + readonly userId: string; +}; + +export type ListBranchesFilters = { + readonly code?: string; + readonly name?: string; + readonly phone?: string; + readonly address?: string; + readonly divisionId?: string; + readonly nfcId?: string; + readonly status?: string; + readonly workingDaysStart?: string; + readonly workingDaysEnd?: string; + readonly workingHoursStart?: string; + readonly workingHoursEnd?: string; + readonly search?: string; + readonly limit: number; + readonly offset: number; +}; diff --git a/src/modules/configuration/branches/branches-read.controller.spec.ts b/src/modules/configuration/branches/branches-read.controller.spec.ts new file mode 100644 index 0000000..4caca20 --- /dev/null +++ b/src/modules/configuration/branches/branches-read.controller.spec.ts @@ -0,0 +1,34 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BranchesReadController } from './branches-read.controller'; +import { BranchesService } from './branches.service'; + +describe('BranchesReadController', () => { + let controller: BranchesReadController; + const service = { + list: jest.fn(), + findById: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + const moduleRef: TestingModule = await Test.createTestingModule({ + controllers: [BranchesReadController], + providers: [{ provide: BranchesService, useValue: service }], + }).compile(); + controller = moduleRef.get(BranchesReadController); + }); + + it('list delegates to the service', async () => { + service.list.mockResolvedValue({ data: [], total: 0 }); + await expect(controller.list({ page: 1 })).resolves.toEqual({ + data: [], + total: 0, + }); + expect(service.list).toHaveBeenCalledWith({ page: 1 }); + }); + + it('findOne delegates to the service', async () => { + service.findById.mockResolvedValue({ id: 'br-1' }); + await expect(controller.findOne('br-1')).resolves.toEqual({ id: 'br-1' }); + }); +}); diff --git a/src/modules/configuration/branches/branches-read.controller.ts b/src/modules/configuration/branches/branches-read.controller.ts new file mode 100644 index 0000000..66610e3 --- /dev/null +++ b/src/modules/configuration/branches/branches-read.controller.ts @@ -0,0 +1,64 @@ +import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiForbiddenResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; +import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator'; +import { + Pagination, + type PaginationResponse, + PaginationMetaDto, +} from '../../../common/http/response'; +import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger'; +import { BranchDto, ListBranchesQueryDto } from './dto/branch.dto'; +import { BranchesService } from './branches.service'; + +export const BRANCH_PRIVILEGE_KEY = 'CONFIGURATION.BRANCH'; + +@ApiTags('branches') +@ApiBearerAuth(BEARER_AUTH_NAME) +@Controller('branches') +export class BranchesReadController { + constructor(private readonly branchesService: BranchesService) {} + + @Get() + @Pagination() + @RequirePrivilege(BRANCH_PRIVILEGE_KEY, 'view') + @ApiOperation({ summary: 'List branches' }) + @ApiOkResponse({ + schema: { + properties: { + data: { + type: 'array', + items: { $ref: '#/components/schemas/BranchDto' }, + }, + meta: { $ref: '#/components/schemas/PaginationMetaDto' }, + }, + }, + }) + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + list( + @Query() query: ListBranchesQueryDto, + ): Promise> { + return this.branchesService.list(query); + } + + @Get(':id') + @RequirePrivilege(BRANCH_PRIVILEGE_KEY, 'view') + @ApiOperation({ summary: 'Get branch detail' }) + @ApiOkResponse({ type: BranchDto }) + @ApiNotFoundResponse() + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + findOne(@Param('id', ParseUUIDPipe) id: string): Promise { + return this.branchesService.findById(id); + } +} + +void PaginationMetaDto; diff --git a/src/modules/configuration/branches/branches-write.controller.spec.ts b/src/modules/configuration/branches/branches-write.controller.spec.ts new file mode 100644 index 0000000..5b257cc --- /dev/null +++ b/src/modules/configuration/branches/branches-write.controller.spec.ts @@ -0,0 +1,83 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BranchesWriteController } from './branches-write.controller'; +import { BranchesService } from './branches.service'; + +const createDto = { + code: 'JKT_01', + name: 'Jakarta Pusat', + phone: '+6281234567890', + address: 'Jl Sudirman No 1', + workingDaysStart: 'monday', + workingDaysEnd: 'friday', + workingHoursStart: '08:00', + workingHoursEnd: '17:00', +}; + +describe('BranchesWriteController', () => { + let controller: BranchesWriteController; + const service = { + create: jest.fn(), + update: jest.fn(), + updateStatus: jest.fn(), + delete: jest.fn(), + bulkDelete: jest.fn(), + bulkUpdateStatus: jest.fn(), + importCsv: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + const moduleRef: TestingModule = await Test.createTestingModule({ + controllers: [BranchesWriteController], + providers: [{ provide: BranchesService, useValue: service }], + }).compile(); + controller = moduleRef.get(BranchesWriteController); + }); + + it('create passes dto fields and user id', async () => { + service.create.mockResolvedValue({ id: 'br-1' }); + await controller.create(createDto, 'user-1'); + expect(service.create).toHaveBeenCalledWith({ + ...createDto, + userId: 'user-1', + }); + }); + + it('update, updateStatus, and delete delegate', async () => { + service.update.mockResolvedValue({ id: 'br-1' }); + service.updateStatus.mockResolvedValue({ id: 'br-1' }); + service.delete.mockResolvedValue(undefined); + await controller.update('br-1', { name: 'Jakarta Pusat' }, 'user-1'); + await controller.updateStatus('br-1', { status: 'active' }, 'user-1'); + await controller.delete('br-1'); + expect(service.update).toHaveBeenCalled(); + expect(service.updateStatus).toHaveBeenCalledWith( + 'br-1', + 'active', + 'user-1', + ); + expect(service.delete).toHaveBeenCalledWith('br-1'); + }); + + it('bulk and import delegate', async () => { + service.bulkDelete.mockResolvedValue({ deleted: 1 }); + service.bulkUpdateStatus.mockResolvedValue({ updated: 1 }); + service.importCsv.mockResolvedValue({ imported: 1 }); + await controller.bulkDelete({ ids: ['br-1'] }); + await controller.bulkStatus( + { ids: ['br-1'], status: 'archived' }, + 'user-1', + ); + await controller.importCsv( + { buffer: Buffer.from('code,name\nJKT_01,Jakarta') }, + 'user-1', + ); + expect(service.importCsv).toHaveBeenCalled(); + }); + + it('importCsv uses empty string when file is missing', async () => { + service.importCsv.mockResolvedValue({ imported: 0 }); + await controller.importCsv(undefined, 'user-1'); + expect(service.importCsv).toHaveBeenCalledWith('', 'user-1'); + }); +}); diff --git a/src/modules/configuration/branches/branches-write.controller.ts b/src/modules/configuration/branches/branches-write.controller.ts new file mode 100644 index 0000000..e03cc43 --- /dev/null +++ b/src/modules/configuration/branches/branches-write.controller.ts @@ -0,0 +1,178 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + HttpCode, + Param, + ParseUUIDPipe, + Patch, + Post, + UploadedFile, + 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 { 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 { + BulkIdsDto, + BulkStatusDto, + CreateBranchDto, + BranchDto, + UpdateBranchDto, + UpdateBranchStatusDto, +} from './dto/branch.dto'; +import { isAllowedCsvUpload } from './branch-fields'; +import { BRANCH_PRIVILEGE_KEY } from './branches-read.controller'; +import { BranchesService } from './branches.service'; + +@ApiTags('branches') +@ApiBearerAuth(BEARER_AUTH_NAME) +@Controller('branches') +export class BranchesWriteController { + constructor(private readonly branchesService: BranchesService) {} + + @Post('import') + @RequirePrivilege(BRANCH_PRIVILEGE_KEY, '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 branches 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.branchesService.importCsv(csv, userId); + } + + @Post('bulk-delete') + @HttpCode(200) + @RequirePrivilege(BRANCH_PRIVILEGE_KEY, 'delete') + @ApiOperation({ summary: 'Bulk delete branches' }) + @ApiOkResponse({ + schema: { properties: { deleted: { type: 'number' } } }, + }) + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + bulkDelete(@Body() dto: BulkIdsDto): Promise<{ deleted: number }> { + return this.branchesService.bulkDelete(dto.ids); + } + + @Post('bulk-status') + @HttpCode(200) + @RequirePrivilege(BRANCH_PRIVILEGE_KEY, 'update') + @ApiOperation({ summary: 'Bulk update branch status' }) + @ApiOkResponse({ + schema: { properties: { updated: { type: 'number' } } }, + }) + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + bulkStatus( + @Body() dto: BulkStatusDto, + @CurrentUser('id') userId: string, + ): Promise<{ updated: number }> { + return this.branchesService.bulkUpdateStatus(dto.ids, dto.status, userId); + } + + @Post() + @RequirePrivilege(BRANCH_PRIVILEGE_KEY, 'create') + @ApiOperation({ summary: 'Create branch' }) + @ApiCreatedResponse({ type: BranchDto }) + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + create( + @Body() dto: CreateBranchDto, + @CurrentUser('id') userId: string, + ): Promise { + return this.branchesService.create({ + ...dto, + userId, + }); + } + + @Patch(':id/status') + @RequirePrivilege(BRANCH_PRIVILEGE_KEY, 'update') + @ApiOperation({ summary: 'Update branch status' }) + @ApiOkResponse({ type: BranchDto }) + @ApiNotFoundResponse() + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + updateStatus( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateBranchStatusDto, + @CurrentUser('id') userId: string, + ): Promise { + return this.branchesService.updateStatus(id, dto.status, userId); + } + + @Patch(':id') + @RequirePrivilege(BRANCH_PRIVILEGE_KEY, 'update') + @ApiOperation({ summary: 'Update branch (not status)' }) + @ApiOkResponse({ type: BranchDto }) + @ApiNotFoundResponse() + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateBranchDto, + @CurrentUser('id') userId: string, + ): Promise { + return this.branchesService.update(id, { + ...dto, + userId, + }); + } + + @Delete(':id') + @HttpCode(204) + @RequirePrivilege(BRANCH_PRIVILEGE_KEY, 'delete') + @ApiOperation({ summary: 'Delete branch' }) + @ApiNoContentResponse() + @ApiNotFoundResponse() + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + async delete(@Param('id', ParseUUIDPipe) id: string): Promise { + await this.branchesService.delete(id); + } +} diff --git a/src/modules/configuration/branches/branches.module.ts b/src/modules/configuration/branches/branches.module.ts new file mode 100644 index 0000000..22d80af --- /dev/null +++ b/src/modules/configuration/branches/branches.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { BranchesReadController } from './branches-read.controller'; +import { BranchesWriteController } from './branches-write.controller'; +import { BranchesRepository } from './branches.repository'; +import { BranchesService } from './branches.service'; + +@Module({ + controllers: [BranchesReadController, BranchesWriteController], + providers: [BranchesRepository, BranchesService], + exports: [BranchesService], +}) +export class BranchesModule {} diff --git a/src/modules/configuration/branches/branches.repository.spec.ts b/src/modules/configuration/branches/branches.repository.spec.ts new file mode 100644 index 0000000..f202673 --- /dev/null +++ b/src/modules/configuration/branches/branches.repository.spec.ts @@ -0,0 +1,269 @@ +import { + BadRequestException, + ConflictException, + NotFoundException, +} from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number'; +import { Status } from '../../../common/value-objects/status/status'; +import { DRIZZLE } from '../../../database/database.module'; +import { BranchesRepository } from './branches.repository'; + +describe('BranchesRepository', () => { + let repository: BranchesRepository; + + const limit = jest.fn(); + const orderBy = jest.fn(); + const offset = 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 del = jest.fn(); + const transaction = jest.fn(); + const $dynamic = jest.fn(); + + const db = { + select, + insert, + update, + delete: del, + transaction, + }; + + const row = { + id: 'br-1', + code: 'JKT_01', + name: 'Jakarta Pusat', + phone: '+6281234567890', + address: 'Jl Sudirman No 1', + latitude: -6.2, + longitude: 106.8, + workingDaysStart: 'monday', + workingDaysEnd: 'friday', + workingHoursStart: '08:00', + workingHoursEnd: '17:00', + nfcId: 'NFC-001', + divisionId: 'div-1', + status: 'draft', + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + createdBy: 'user-1', + updatedBy: 'user-1', + }; + + const createInput = { + code: 'JKT_01', + name: 'Jakarta Pusat', + phone: PhoneNumber.create('+6281234567890'), + address: 'Jl Sudirman No 1', + workingDaysStart: 'monday', + workingDaysEnd: 'friday', + workingHoursStart: '08:00', + workingHoursEnd: '17:00', + userId: 'user-1', + }; + + beforeEach(async () => { + jest.clearAllMocks(); + where.mockImplementation(() => ({ limit, orderBy })); + orderBy.mockImplementation(() => ({ limit })); + limit.mockImplementation(() => ({ offset })); + offset.mockResolvedValue([row]); + from.mockImplementation(() => ({ + where, + $dynamic, + })); + $dynamic.mockReturnValue({ where }); + select.mockImplementation(() => ({ from })); + values.mockReturnValue({ returning }); + insert.mockReturnValue({ values }); + set.mockReturnValue({ where }); + update.mockReturnValue({ set }); + del.mockReturnValue({ where }); + returning.mockResolvedValue([row]); + where.mockImplementation(() => ({ limit, orderBy, returning })); + + const moduleRef: TestingModule = await Test.createTestingModule({ + providers: [BranchesRepository, { provide: DRIZZLE, useValue: db }], + }).compile(); + repository = moduleRef.get(BranchesRepository); + }); + + it('findById maps a row to domain Branch', async () => { + limit.mockResolvedValueOnce([row]); + const branch = await repository.findById('br-1'); + expect(branch).toMatchObject({ + id: 'br-1', + code: 'JKT_01', + name: 'Jakarta Pusat', + createdBy: 'user-1', + }); + expect(branch?.phone.value).toBe('+6281234567890'); + expect(branch?.status.value).toBe('draft'); + expect(branch?.createdAt.value).toBe(1_700_000_000_000); + }); + + it('findById returns null when missing', async () => { + limit.mockResolvedValueOnce([]); + await expect(repository.findById('missing')).resolves.toBeNull(); + }); + + it('findByCode maps a row', async () => { + limit.mockResolvedValueOnce([row]); + const branch = await repository.findByCode('JKT_01'); + expect(branch?.code).toBe('JKT_01'); + }); + + it('list returns mapped rows and total', async () => { + select + .mockImplementationOnce(() => ({ + from: () => ({ + where: () => Promise.resolve([{ total: 1 }]), + }), + })) + .mockImplementationOnce(() => ({ + from: () => ({ + $dynamic: () => ({ + where: () => ({ + orderBy: () => ({ + limit: () => ({ + offset: () => Promise.resolve([row]), + }), + }), + }), + }), + }), + })); + + const result = await repository.list({ + name: 'Jakarta', + code: 'JKT', + phone: '+628', + address: 'Sudirman', + divisionId: 'div-1', + nfcId: 'NFC-001', + status: 'draft', + workingDaysStart: 'monday', + workingDaysEnd: 'friday', + workingHoursStart: '08:00', + workingHoursEnd: '17:00', + search: 'sudirman', + limit: 10, + offset: 0, + }); + expect(result.total).toBe(1); + expect(result.data[0].code).toBe('JKT_01'); + }); + + it('create inserts and maps unique violations', async () => { + returning.mockResolvedValueOnce([row]); + const created = await repository.create(createInput); + expect(created.code).toBe('JKT_01'); + + returning.mockRejectedValueOnce({ code: '23505' }); + await expect(repository.create(createInput)).rejects.toBeInstanceOf( + ConflictException, + ); + + returning.mockRejectedValueOnce({ + code: '23505', + constraint: 'branches_nfc_id_unique', + }); + await expect(repository.create(createInput)).rejects.toMatchObject({ + message: 'Branch NFC ID already exists', + }); + }); + + it('create maps missing division foreign keys', async () => { + returning.mockRejectedValueOnce({ code: '23503' }); + await expect(repository.create(createInput)).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('create rethrows unknown errors', async () => { + returning.mockRejectedValue(new Error('db down')); + await expect(repository.create(createInput)).rejects.toThrow('db down'); + }); + + it('createMany returns 0 for an empty batch and inserts otherwise', async () => { + await expect(repository.createMany([])).resolves.toBe(0); + transaction.mockImplementation( + async (fn: (tx: typeof db) => Promise) => { + await fn(db); + }, + ); + await expect(repository.createMany([createInput])).resolves.toBe(1); + }); + + it('updateStatus returns the mapped row', async () => { + returning.mockResolvedValueOnce([row]); + const updated = await repository.updateStatus( + 'br-1', + Status.create('active'), + 'user-1', + ); + expect(updated.id).toBe('br-1'); + }); + + it('update throws when missing', async () => { + limit.mockResolvedValueOnce([]); + await expect( + repository.update('missing', { userId: 'user-1' }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('update maps a row when present', async () => { + limit.mockResolvedValueOnce([row]); + returning.mockResolvedValueOnce([row]); + const updated = await repository.update('br-1', { + name: 'Jakarta Selatan', + userId: 'user-1', + }); + expect(updated.code).toBe('JKT_01'); + }); + + it('updateStatus throws when missing', async () => { + returning.mockResolvedValueOnce([]); + await expect( + repository.updateStatus('missing', Status.create('active'), 'user-1'), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('delete throws when missing', async () => { + returning.mockResolvedValueOnce([]); + await expect(repository.delete('missing')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('bulkUpdateStatus and bulkDelete return 0 for empty ids', async () => { + await expect( + repository.bulkUpdateStatus([], Status.create('active'), 'user-1'), + ).resolves.toBe(0); + await expect(repository.bulkDelete([])).resolves.toBe(0); + }); + + it('bulkUpdateStatus and bulkDelete return affected counts', async () => { + returning.mockResolvedValue([{ id: 'br-1' }, { id: 'br-2' }]); + await expect( + repository.bulkUpdateStatus( + ['br-1', 'br-2'], + Status.create('active'), + 'user-1', + ), + ).resolves.toBe(2); + returning.mockResolvedValue([{ id: 'br-1' }]); + await expect(repository.bulkDelete(['br-1'])).resolves.toBe(1); + }); + + it('extendListQuery is a passthrough hook', () => { + const qb = { join: true }; + expect(repository.extendListQuery(qb, { limit: 10, offset: 0 })).toBe(qb); + }); +}); diff --git a/src/modules/configuration/branches/branches.repository.ts b/src/modules/configuration/branches/branches.repository.ts new file mode 100644 index 0000000..db5491a --- /dev/null +++ b/src/modules/configuration/branches/branches.repository.ts @@ -0,0 +1,339 @@ +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm'; +import { DateTime } from '../../../common/value-objects/date-time/date-time'; +import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number'; +import { Status } from '../../../common/value-objects/status/status'; +import { DRIZZLE, type DrizzleDB } from '../../../database/database.module'; +import { + branches, + type BranchRow, + type NewBranchRow, +} from '../../../database/branches-table'; +import type { + Branch, + CreateBranchInput, + ListBranchesFilters, + UpdateBranchInput, +} from './branch'; + +@Injectable() +export class BranchesRepository { + constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {} + + async list( + filters: ListBranchesFilters, + ): Promise<{ data: Branch[]; total: number }> { + const where = this.buildListWhere(filters); + const totalRows = await this.db + .select({ total: count() }) + .from(branches) + .where(where); + const totalRow = totalRows[0]; + + let qb = this.db.select().from(branches).$dynamic(); + qb = this.extendListQuery(qb, filters); + const rows = await qb + .where(where) + .orderBy(asc(branches.code)) + .limit(filters.limit) + .offset(filters.offset); + + return { + data: rows.map((row) => this.toDomain(row)), + total: Number(totalRow?.total ?? 0), + }; + } + + /** + * Hook for modules to add joins/extra predicates without forking list. + */ + extendListQuery(qb: T, filters: ListBranchesFilters): T { + void filters; + return qb; + } + + async findById(id: string): Promise { + const rows: BranchRow[] = await this.db + .select() + .from(branches) + .where(eq(branches.id, id)) + .limit(1); + const row = rows[0]; + return row ? this.toDomain(row) : null; + } + + async findByCode(code: string): Promise { + const rows = await this.db + .select() + .from(branches) + .where(eq(branches.code, code)) + .limit(1); + const row = rows[0]; + return row ? this.toDomain(row) : null; + } + + async create(input: CreateBranchInput): Promise { + const now = DateTime.fromUnixMs(Date.now()); + const status = input.status ?? Status.create(Status.DEFAULT); + try { + const inserted = await this.db + .insert(branches) + .values(this.toInsertValues(input, status, now, input.userId)) + .returning(); + const row = inserted[0]; + return this.toDomain(row); + } catch (error) { + this.rethrowConstraintViolation(error); + } + } + + async createMany(inputs: CreateBranchInput[]): Promise { + if (inputs.length === 0) { + return 0; + } + const now = DateTime.fromUnixMs(Date.now()); + try { + await this.db.transaction(async (tx) => { + for (const input of inputs) { + const status = input.status ?? Status.create(Status.DEFAULT); + await tx + .insert(branches) + .values(this.toInsertValues(input, status, now, input.userId)); + } + }); + return inputs.length; + } catch (error) { + this.rethrowConstraintViolation(error); + } + } + + async update(id: string, input: UpdateBranchInput): Promise { + const existing = await this.findById(id); + if (!existing) { + throw new NotFoundException('Branch not found'); + } + const now = DateTime.fromUnixMs(Date.now()); + try { + const values: Partial = { + code: input.code ?? existing.code, + name: input.name ?? existing.name, + phone: input.phone?.value ?? existing.phone.value, + address: input.address ?? existing.address, + latitude: + input.latitude !== undefined ? input.latitude : existing.latitude, + longitude: + input.longitude !== undefined ? input.longitude : existing.longitude, + workingDaysStart: input.workingDaysStart ?? existing.workingDaysStart, + workingDaysEnd: input.workingDaysEnd ?? existing.workingDaysEnd, + workingHoursStart: + input.workingHoursStart ?? existing.workingHoursStart, + workingHoursEnd: input.workingHoursEnd ?? existing.workingHoursEnd, + nfcId: input.nfcId !== undefined ? input.nfcId : existing.nfcId, + divisionId: + input.divisionId !== undefined + ? input.divisionId + : existing.divisionId, + updatedAt: now.value, + updatedBy: input.userId, + }; + const updated = await this.db + .update(branches) + .set(values) + .where(eq(branches.id, id)) + .returning(); + const row = updated[0]; + if (!row) { + throw new NotFoundException('Branch not found'); + } + return this.toDomain(row); + } catch (error) { + this.rethrowConstraintViolation(error); + } + } + + async updateStatus( + id: string, + status: Status, + userId: string, + ): Promise { + const now = DateTime.fromUnixMs(Date.now()); + const updated = await this.db + .update(branches) + .set({ + status: status.value, + updatedAt: now.value, + updatedBy: userId, + }) + .where(eq(branches.id, id)) + .returning(); + const row = updated[0]; + if (!row) { + throw new NotFoundException('Branch not found'); + } + return this.toDomain(row); + } + + async bulkUpdateStatus( + ids: string[], + status: Status, + userId: string, + ): Promise { + if (ids.length === 0) { + return 0; + } + const now = DateTime.fromUnixMs(Date.now()); + const rows = await this.db + .update(branches) + .set({ + status: status.value, + updatedAt: now.value, + updatedBy: userId, + }) + .where(inArray(branches.id, ids)) + .returning({ id: branches.id }); + return rows.length; + } + + async delete(id: string): Promise { + const deleted = await this.db + .delete(branches) + .where(eq(branches.id, id)) + .returning({ id: branches.id }); + if (deleted.length === 0) { + throw new NotFoundException('Branch not found'); + } + } + + async bulkDelete(ids: string[]): Promise { + if (ids.length === 0) { + return 0; + } + const deleted = await this.db + .delete(branches) + .where(inArray(branches.id, ids)) + .returning({ id: branches.id }); + return deleted.length; + } + + private buildListWhere(filters: ListBranchesFilters): SQL | undefined { + const parts: SQL[] = []; + if (filters.code) { + parts.push(ilike(branches.code, `%${filters.code}%`)); + } + if (filters.name) { + parts.push(ilike(branches.name, `%${filters.name}%`)); + } + if (filters.phone) { + parts.push(ilike(branches.phone, `%${filters.phone}%`)); + } + if (filters.address) { + parts.push(ilike(branches.address, `%${filters.address}%`)); + } + if (filters.divisionId) { + parts.push(eq(branches.divisionId, filters.divisionId)); + } + if (filters.nfcId) { + parts.push(eq(branches.nfcId, filters.nfcId)); + } + if (filters.status) { + parts.push(eq(branches.status, filters.status)); + } + if (filters.workingDaysStart) { + parts.push(eq(branches.workingDaysStart, filters.workingDaysStart)); + } + if (filters.workingDaysEnd) { + parts.push(eq(branches.workingDaysEnd, filters.workingDaysEnd)); + } + if (filters.workingHoursStart) { + parts.push(eq(branches.workingHoursStart, filters.workingHoursStart)); + } + if (filters.workingHoursEnd) { + parts.push(eq(branches.workingHoursEnd, filters.workingHoursEnd)); + } + if (filters.search) { + const search = or( + ilike(branches.code, `%${filters.search}%`), + ilike(branches.name, `%${filters.search}%`), + ilike(branches.address, `%${filters.search}%`), + ); + if (search) { + parts.push(search); + } + } + if (parts.length === 0) { + return undefined; + } + return parts.length === 1 ? parts[0] : and(...parts); + } + + private toInsertValues( + input: CreateBranchInput, + status: Status, + now: DateTime, + userId: string, + ) { + return { + code: input.code, + name: input.name, + phone: input.phone.value, + address: input.address, + latitude: input.latitude ?? null, + longitude: input.longitude ?? null, + workingDaysStart: input.workingDaysStart, + workingDaysEnd: input.workingDaysEnd, + workingHoursStart: input.workingHoursStart, + workingHoursEnd: input.workingHoursEnd, + nfcId: input.nfcId ?? null, + divisionId: input.divisionId ?? null, + status: status.value, + createdAt: now.value, + updatedAt: now.value, + createdBy: userId, + updatedBy: userId, + }; + } + + private toDomain(row: BranchRow): Branch { + return { + id: row.id, + code: row.code, + name: row.name, + phone: PhoneNumber.create(row.phone), + address: row.address, + latitude: row.latitude, + longitude: row.longitude, + workingDaysStart: row.workingDaysStart, + workingDaysEnd: row.workingDaysEnd, + workingHoursStart: row.workingHoursStart, + workingHoursEnd: row.workingHoursEnd, + nfcId: row.nfcId, + divisionId: row.divisionId, + 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 = error as { code?: string; constraint?: string }; + if (err.code === '23505') { + const constraint = err.constraint ?? ''; + if (constraint.includes('nfc')) { + throw new ConflictException('Branch NFC ID already exists'); + } + throw new ConflictException('Branch code already exists'); + } + if (err.code === '23503') { + throw new BadRequestException('Division does not exist'); + } + throw error; + } +} diff --git a/src/modules/configuration/branches/branches.service.spec.ts b/src/modules/configuration/branches/branches.service.spec.ts new file mode 100644 index 0000000..d83aadc --- /dev/null +++ b/src/modules/configuration/branches/branches.service.spec.ts @@ -0,0 +1,324 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { DateTime } from '../../../common/value-objects/date-time/date-time'; +import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number'; +import { Status } from '../../../common/value-objects/status/status'; +import type { Branch } from './branch'; +import { BranchesRepository } from './branches.repository'; +import { BranchesService } from './branches.service'; + +describe('BranchesService', () => { + let service: BranchesService; + let repository: jest.Mocked< + Pick< + BranchesRepository, + | 'list' + | 'findById' + | 'create' + | 'createMany' + | 'update' + | 'updateStatus' + | 'bulkUpdateStatus' + | 'delete' + | 'bulkDelete' + > + >; + + const now = DateTime.fromUnixMs(1_700_000_000_000); + const sample: Branch = { + id: 'br-1', + code: 'JKT_01', + name: 'Jakarta Pusat', + phone: PhoneNumber.create('+6281234567890'), + address: 'Jl Sudirman No 1', + latitude: -6.2, + longitude: 106.8, + workingDaysStart: 'monday', + workingDaysEnd: 'friday', + workingHoursStart: '08:00', + workingHoursEnd: '17:00', + nfcId: 'NFC-001', + divisionId: 'div-1', + status: Status.create('draft'), + createdAt: now, + updatedAt: now, + createdBy: 'user-1', + updatedBy: 'user-1', + }; + + const createInput = { + code: 'JKT_01', + name: 'Jakarta Pusat', + phone: '+6281234567890', + address: 'Jl Sudirman No 1', + workingDaysStart: 'monday', + workingDaysEnd: 'friday', + workingHoursStart: '08:00', + workingHoursEnd: '17:00', + userId: 'user-1', + }; + + beforeEach(async () => { + repository = { + list: jest.fn(), + findById: jest.fn(), + create: jest.fn(), + createMany: jest.fn(), + update: jest.fn(), + updateStatus: jest.fn(), + bulkUpdateStatus: jest.fn(), + delete: jest.fn(), + bulkDelete: jest.fn(), + }; + + const moduleRef: TestingModule = await Test.createTestingModule({ + providers: [ + BranchesService, + { provide: BranchesRepository, useValue: repository }, + ], + }).compile(); + + service = moduleRef.get(BranchesService); + }); + + it('list maps visible fields and pagination', async () => { + repository.list.mockResolvedValue({ data: [sample], total: 1 }); + const result = await service.list({ page: 1, limit: 10 }); + expect(result.total).toBe(1); + expect(result.data[0]).toMatchObject({ + id: 'br-1', + code: 'JKT_01', + name: 'Jakarta Pusat', + phone: '+6281234567890', + status: 'draft', + createdAt: now.value, + }); + expect(service.visibleFields).toContain('phone'); + }); + + it('findById throws when missing', async () => { + repository.findById.mockResolvedValue(null); + await expect(service.findById('missing')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('findById returns mapped item', async () => { + repository.findById.mockResolvedValue(sample); + const result = await service.findById('br-1'); + expect(result.id).toBe('br-1'); + expect(result.phone).toBe('+6281234567890'); + }); + + it('create defaults status to draft and stores E.164 phone', async () => { + repository.create.mockResolvedValue(sample); + await service.create(createInput); + const arg = repository.create.mock.calls[0][0]; + expect(arg.status?.value).toBe('draft'); + expect(arg.phone.value).toBe('+6281234567890'); + expect(arg.code).toBe('JKT_01'); + }); + + it('create rejects invalid phone without echoing input', async () => { + await expect( + service.create({ ...createInput, phone: '081234567890' }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(repository.create).not.toHaveBeenCalled(); + }); + + it('create rejects invalid weekday and hours', async () => { + await expect( + service.create({ ...createInput, workingDaysStart: 'Monday' }), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + service.create({ ...createInput, workingHoursStart: '8:00' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('create rejects out of range coordinates', async () => { + await expect( + service.create({ ...createInput, latitude: 91 }), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + service.create({ ...createInput, longitude: 181 }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('create rejects invalid name or code', async () => { + await expect( + service.create({ ...createInput, name: 'Jakarta1' }), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + service.create({ ...createInput, code: 'JKT 01' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('update rejects status field', async () => { + await expect( + service.update('br-1', { status: 'active', userId: 'user-1' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('updateStatus updates via repository', async () => { + repository.updateStatus.mockResolvedValue(sample); + await service.updateStatus('br-1', 'active', 'user-1'); + expect(repository.updateStatus).toHaveBeenCalledWith( + 'br-1', + expect.objectContaining({ value: 'active' }), + 'user-1', + ); + }); + + it('delete, bulkDelete, and bulkUpdateStatus delegate', async () => { + repository.delete.mockResolvedValue(undefined); + repository.bulkDelete.mockResolvedValue(2); + repository.bulkUpdateStatus.mockResolvedValue(2); + await service.delete('br-1'); + await expect(service.bulkDelete(['a', 'b'])).resolves.toEqual({ + deleted: 2, + }); + await expect( + service.bulkUpdateStatus(['a', 'b'], 'archived', 'user-1'), + ).resolves.toEqual({ updated: 2 }); + }); + + it('importCsv imports valid rows', async () => { + repository.createMany.mockResolvedValue(1); + const csv = + 'code,name,phone,address,workingDaysStart,workingDaysEnd,workingHoursStart,workingHoursEnd,status\n' + + 'JKT_01,Jakarta Pusat,+6281234567890,Jl Sudirman No 1,monday,friday,08:00,17:00,draft'; + const result = await service.importCsv(csv, 'user-1'); + expect(result.imported).toBe(1); + expect(repository.createMany).toHaveBeenCalledTimes(1); + }); + + it('importCsv fails the batch on invalid phone', async () => { + const csv = + 'code,name,phone,address,workingDaysStart,workingDaysEnd,workingHoursStart,workingHoursEnd\n' + + 'JKT_01,Jakarta Pusat,081234,Jl Sudirman No 1,monday,friday,08:00,17:00'; + await expect(service.importCsv(csv, 'user-1')).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(repository.createMany).not.toHaveBeenCalled(); + }); + + it('importCsv rejects empty and headerless files', async () => { + await expect(service.importCsv('', 'user-1')).rejects.toBeInstanceOf( + BadRequestException, + ); + await expect( + service.importCsv('code,name\nJKT_01,Jakarta', 'user-1'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('update validates fields and rejects invalid address or NFC', async () => { + repository.update.mockResolvedValue(sample); + await service.update('br-1', { + name: 'Jakarta Selatan', + phone: '+6281234567890', + address: 'Jl Sudirman No 2', + latitude: -6.2, + longitude: 106.8, + workingDaysStart: 'tuesday', + workingDaysEnd: 'saturday', + workingHoursStart: '09:00', + workingHoursEnd: '18:00', + nfcId: 'NFC-002', + divisionId: null, + userId: 'user-1', + }); + expect(repository.update).toHaveBeenCalledWith( + 'br-1', + expect.objectContaining({ + name: 'Jakarta Selatan', + nfcId: 'NFC-002', + divisionId: null, + userId: 'user-1', + }), + ); + + await expect( + service.update('br-1', { address: '', userId: 'user-1' }), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + service.update('br-1', { nfcId: 'A'.repeat(65), userId: 'user-1' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('update can clear optional NFC and coordinates', async () => { + repository.update.mockResolvedValue(sample); + await service.update('br-1', { + nfcId: null, + latitude: null, + longitude: null, + userId: 'user-1', + }); + expect(repository.update).toHaveBeenCalledWith( + 'br-1', + expect.objectContaining({ + nfcId: null, + latitude: null, + longitude: null, + }), + ); + }); + + it('importCsv accepts quoted addresses and rejects invalid divisionId', async () => { + repository.createMany.mockResolvedValue(1); + const quoted = + 'code,name,phone,address,workingDaysStart,workingDaysEnd,workingHoursStart,workingHoursEnd\n' + + 'JKT_01,Jakarta Pusat,+6281234567890,"Jl Sudirman No 1, Blok A",monday,friday,08:00,17:00'; + await expect(service.importCsv(quoted, 'user-1')).resolves.toEqual({ + imported: 1, + }); + expect(repository.createMany.mock.calls[0][0][0].address).toBe( + 'Jl Sudirman No 1, Blok A', + ); + + const badDivision = + 'code,name,phone,address,workingDaysStart,workingDaysEnd,workingHoursStart,workingHoursEnd,divisionId\n' + + 'JKT_02,Jakarta Pusat,+6281234567890,Jl Sudirman No 1,monday,friday,08:00,17:00,not-a-uuid'; + await expect(service.importCsv(badDivision, 'user-1')).rejects.toThrow( + BadRequestException, + ); + try { + await service.importCsv(badDivision, 'user-1'); + } catch (error) { + const body = (error as BadRequestException).getResponse() as { + errors: string[]; + }; + expect(body.errors[0]).toContain('Invalid divisionId'); + } + }); + + it('importCsv reports the original line number after blank lines', async () => { + const csv = + 'code,name,phone,address,workingDaysStart,workingDaysEnd,workingHoursStart,workingHoursEnd\n' + + '\n' + + 'JKT_01,Jakarta Pusat,081234,Jl Sudirman No 1,monday,friday,08:00,17:00'; + try { + await service.importCsv(csv, 'user-1'); + throw new Error('expected importCsv to fail'); + } catch (error) { + expect(error).toBeInstanceOf(BadRequestException); + const body = (error as BadRequestException).getResponse() as { + errors: string[]; + }; + expect(body.errors[0]).toMatch(/^row 3: /); + } + }); + + it('importCsv rejects oversized files', async () => { + const huge = [ + 'code,name,phone,address,workingDaysStart,workingDaysEnd,workingHoursStart,workingHoursEnd', + ...Array.from( + { length: 501 }, + (_, i) => + `C${i},Jakarta Pusat,+6281234567890,Jl Sudirman,monday,friday,08:00,17:00`, + ), + ].join('\n'); + await expect(service.importCsv(huge, 'user-1')).rejects.toBeInstanceOf( + BadRequestException, + ); + }); +}); diff --git a/src/modules/configuration/branches/branches.service.ts b/src/modules/configuration/branches/branches.service.ts new file mode 100644 index 0000000..7cd44b6 --- /dev/null +++ b/src/modules/configuration/branches/branches.service.ts @@ -0,0 +1,487 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import type { PaginationResponse } from '../../../common/http/response'; +import { toListPage } from '../../../common/http/response'; +import { InvalidPhoneNumberError } from '../../../common/value-objects/phone-number/invalid-phone-number.error'; +import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number'; +import { Status } from '../../../common/value-objects/status/status'; +import type { Branch, CreateBranchInput, UpdateBranchInput } from './branch'; +import { + isValidBranchAddress, + isValidBranchCode, + isValidBranchName, + isValidDivisionId, + isValidLatitude, + isValidLongitude, + isValidNfcId, + isValidWeekday, + isValidWorkingHours, + parseCsvRecord, +} from './branch-fields'; +import { BranchesRepository } from './branches.repository'; + +export type ListBranchesQuery = { + readonly code?: string; + readonly name?: string; + readonly phone?: string; + readonly address?: string; + readonly divisionId?: string; + readonly nfcId?: string; + readonly status?: string; + readonly workingDaysStart?: string; + readonly workingDaysEnd?: string; + readonly workingHoursStart?: string; + readonly workingHoursEnd?: string; + readonly search?: string; + readonly page?: number; + readonly limit?: number; + readonly offset?: number; +}; + +const VISIBLE_FIELDS = [ + 'id', + 'code', + 'name', + 'phone', + 'address', + 'latitude', + 'longitude', + 'workingDaysStart', + 'workingDaysEnd', + 'workingHoursStart', + 'workingHoursEnd', + 'nfcId', + 'divisionId', + 'status', + 'createdAt', + 'updatedAt', + 'createdBy', + 'updatedBy', +] as const; + +const CSV_REQUIRED_HEADERS = [ + 'code', + 'name', + 'phone', + 'address', + 'workingdaysstart', + 'workingdaysend', + 'workinghoursstart', + 'workinghoursend', +] as const; + +@Injectable() +export class BranchesService { + constructor(private readonly branchesRepository: BranchesRepository) {} + + async list( + query: ListBranchesQuery, + ): Promise>> { + const page = toListPage(query); + const { data, total } = await this.branchesRepository.list({ + code: query.code, + name: query.name, + phone: query.phone, + address: query.address, + divisionId: query.divisionId, + nfcId: query.nfcId, + status: query.status, + workingDaysStart: query.workingDaysStart, + workingDaysEnd: query.workingDaysEnd, + workingHoursStart: query.workingHoursStart, + workingHoursEnd: query.workingHoursEnd, + search: query.search, + limit: page.limit, + offset: page.offset, + }); + return { + data: data.map((item) => this.toListItem(item)), + total, + }; + } + + async findById( + id: string, + ): Promise> { + const branch = await this.branchesRepository.findById(id); + if (!branch) { + throw new NotFoundException('Branch not found'); + } + return this.toListItem(branch); + } + + async create(input: { + code: string; + name: string; + phone: string; + address: string; + latitude?: number | null; + longitude?: number | null; + workingDaysStart: string; + workingDaysEnd: string; + workingHoursStart: string; + workingHoursEnd: string; + nfcId?: string | null; + divisionId?: string | null; + status?: string; + userId: string; + }): Promise> { + const created = await this.branchesRepository.create( + this.toCreateInput(input), + ); + return this.toListItem(created); + } + + async update( + id: string, + input: { + code?: string; + name?: string; + phone?: string; + address?: string; + latitude?: number | null; + longitude?: number | null; + workingDaysStart?: string; + workingDaysEnd?: string; + workingHoursStart?: string; + workingHoursEnd?: string; + nfcId?: string | null; + divisionId?: string | null; + status?: unknown; + userId: string; + }, + ): Promise> { + if (input.status !== undefined) { + throw new BadRequestException('status cannot be updated via PATCH'); + } + const payload: UpdateBranchInput = { + code: input.code !== undefined ? this.assertCode(input.code) : undefined, + name: input.name !== undefined ? this.assertName(input.name) : undefined, + phone: + input.phone !== undefined ? this.assertPhone(input.phone) : undefined, + address: + input.address !== undefined + ? this.assertAddress(input.address) + : undefined, + latitude: + input.latitude !== undefined + ? this.assertLatitude(input.latitude) + : undefined, + longitude: + input.longitude !== undefined + ? this.assertLongitude(input.longitude) + : undefined, + workingDaysStart: + input.workingDaysStart !== undefined + ? this.assertWeekday(input.workingDaysStart, 'workingDaysStart') + : undefined, + workingDaysEnd: + input.workingDaysEnd !== undefined + ? this.assertWeekday(input.workingDaysEnd, 'workingDaysEnd') + : undefined, + workingHoursStart: + input.workingHoursStart !== undefined + ? this.assertWorkingHours( + input.workingHoursStart, + 'workingHoursStart', + ) + : undefined, + workingHoursEnd: + input.workingHoursEnd !== undefined + ? this.assertWorkingHours(input.workingHoursEnd, 'workingHoursEnd') + : undefined, + nfcId: + input.nfcId !== undefined ? this.assertNfcId(input.nfcId) : undefined, + divisionId: + input.divisionId !== undefined ? (input.divisionId ?? null) : undefined, + userId: input.userId, + }; + const updated = await this.branchesRepository.update(id, payload); + return this.toListItem(updated); + } + + async updateStatus( + id: string, + statusRaw: string, + userId: string, + ): Promise> { + const status = Status.create(statusRaw); + const updated = await this.branchesRepository.updateStatus( + id, + status, + userId, + ); + return this.toListItem(updated); + } + + async bulkUpdateStatus( + ids: string[], + statusRaw: string, + userId: string, + ): Promise<{ updated: number }> { + const status = Status.create(statusRaw); + const updated = await this.branchesRepository.bulkUpdateStatus( + ids, + status, + userId, + ); + return { updated }; + } + + async delete(id: string): Promise { + await this.branchesRepository.delete(id); + } + + async bulkDelete(ids: string[]): Promise<{ deleted: number }> { + const deleted = await this.branchesRepository.bulkDelete(ids); + return { deleted }; + } + + async importCsv(csv: string, userId: string): Promise<{ imported: number }> { + 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'); + } + if (filled.length > 501) { + throw new BadRequestException('CSV exceeds maximum of 500 data rows'); + } + + const header = parseCsvRecord(filled[0].line).map((h) => + h.trim().toLowerCase(), + ); + const missing = CSV_REQUIRED_HEADERS.filter((h) => header.indexOf(h) < 0); + if (missing.length > 0) { + throw new BadRequestException('CSV must include required headers'); + } + + const idx = (key: string) => header.indexOf(key); + const errors: string[] = []; + const rows: CreateBranchInput[] = []; + for (let i = 1; i < filled.length; i++) { + const cols = parseCsvRecord(filled[i].line); + const rowNum = filled[i].lineNo; + try { + const statusRaw = idx('status') >= 0 ? cols[idx('status')] : ''; + const latitudeRaw = + idx('latitude') >= 0 ? cols[idx('latitude')] : undefined; + const longitudeRaw = + idx('longitude') >= 0 ? cols[idx('longitude')] : undefined; + const nfcRaw = idx('nfcid') >= 0 ? cols[idx('nfcid')] : undefined; + const divisionRaw = + idx('divisionid') >= 0 ? cols[idx('divisionid')] : undefined; + rows.push( + this.toCreateInput({ + code: cols[idx('code')] ?? '', + name: cols[idx('name')] ?? '', + phone: cols[idx('phone')] ?? '', + address: cols[idx('address')] ?? '', + workingDaysStart: cols[idx('workingdaysstart')] ?? '', + workingDaysEnd: cols[idx('workingdaysend')] ?? '', + workingHoursStart: cols[idx('workinghoursstart')] ?? '', + workingHoursEnd: cols[idx('workinghoursend')] ?? '', + latitude: + latitudeRaw === undefined || latitudeRaw === '' + ? undefined + : Number(latitudeRaw), + longitude: + longitudeRaw === undefined || longitudeRaw === '' + ? undefined + : Number(longitudeRaw), + nfcId: nfcRaw || undefined, + divisionId: divisionRaw || undefined, + status: statusRaw || undefined, + userId, + }), + ); + } catch (error) { + const reason = + error instanceof BadRequestException ? error.message : 'invalid data'; + errors.push(`row ${rowNum}: ${reason}`); + } + } + + if (errors.length > 0) { + throw new BadRequestException({ + message: 'CSV validation failed', + errors, + }); + } + + await this.branchesRepository.createMany(rows); + return { imported: rows.length }; + } + + toListItem(branch: Branch) { + return { + id: branch.id, + code: branch.code, + name: branch.name, + phone: branch.phone.value, + address: branch.address, + latitude: branch.latitude, + longitude: branch.longitude, + workingDaysStart: branch.workingDaysStart, + workingDaysEnd: branch.workingDaysEnd, + workingHoursStart: branch.workingHoursStart, + workingHoursEnd: branch.workingHoursEnd, + nfcId: branch.nfcId, + divisionId: branch.divisionId, + status: branch.status.value, + createdAt: branch.createdAt.value, + updatedAt: branch.updatedAt.value, + createdBy: branch.createdBy, + updatedBy: branch.updatedBy, + }; + } + + get visibleFields(): readonly string[] { + return VISIBLE_FIELDS; + } + + private toCreateInput(input: { + code: string; + name: string; + phone: string; + address: string; + latitude?: number | null; + longitude?: number | null; + workingDaysStart: string; + workingDaysEnd: string; + workingHoursStart: string; + workingHoursEnd: string; + nfcId?: string | null; + divisionId?: string | null; + status?: string; + userId: string; + }): CreateBranchInput { + return { + code: this.assertCode(input.code), + name: this.assertName(input.name), + phone: this.assertPhone(input.phone), + address: this.assertAddress(input.address), + latitude: this.assertLatitude(input.latitude ?? null), + longitude: this.assertLongitude(input.longitude ?? null), + workingDaysStart: this.assertWeekday( + input.workingDaysStart, + 'workingDaysStart', + ), + workingDaysEnd: this.assertWeekday( + input.workingDaysEnd, + 'workingDaysEnd', + ), + workingHoursStart: this.assertWorkingHours( + input.workingHoursStart, + 'workingHoursStart', + ), + workingHoursEnd: this.assertWorkingHours( + input.workingHoursEnd, + 'workingHoursEnd', + ), + nfcId: this.assertNfcId(input.nfcId ?? null), + divisionId: this.assertDivisionId(input.divisionId ?? null), + status: input.status + ? Status.create(input.status) + : Status.create(Status.DEFAULT), + userId: input.userId, + }; + } + + private assertName(raw: string): string { + const name = raw.trim(); + if (!isValidBranchName(name)) { + throw new BadRequestException('Invalid branch name'); + } + return name; + } + + private assertCode(raw: string): string { + const code = raw.trim(); + if (!isValidBranchCode(code)) { + throw new BadRequestException('Invalid branch code'); + } + return code; + } + + private assertAddress(raw: string): string { + const address = raw.trim(); + if (!isValidBranchAddress(address)) { + throw new BadRequestException('Invalid branch address'); + } + return address; + } + + private assertPhone(raw: string): PhoneNumber { + try { + return PhoneNumber.create(raw); + } catch (error) { + if (error instanceof InvalidPhoneNumberError) { + throw new BadRequestException('Invalid phone number'); + } + throw error; + } + } + + private assertWeekday(raw: string, field: string): string { + const value = raw.trim(); + if (!isValidWeekday(value)) { + throw new BadRequestException(`Invalid ${field}`); + } + return value; + } + + private assertWorkingHours(raw: string, field: string): string { + const value = raw.trim(); + if (!isValidWorkingHours(value)) { + throw new BadRequestException(`Invalid ${field}`); + } + return value; + } + + private assertLatitude(raw: number | null): number | null { + if (raw === null) { + return null; + } + if (!isValidLatitude(raw)) { + throw new BadRequestException('Invalid latitude'); + } + return raw; + } + + private assertLongitude(raw: number | null): number | null { + if (raw === null) { + return null; + } + if (!isValidLongitude(raw)) { + throw new BadRequestException('Invalid longitude'); + } + return raw; + } + + private assertNfcId(raw: string | null): string | null { + if (raw === null || raw.trim() === '') { + return null; + } + const value = raw.trim(); + if (!isValidNfcId(value)) { + throw new BadRequestException('Invalid NFC ID'); + } + return value; + } + + private assertDivisionId(raw: string | null): string | null { + if (raw === null || raw.trim() === '') { + return null; + } + const value = raw.trim(); + if (!isValidDivisionId(value)) { + throw new BadRequestException('Invalid divisionId'); + } + return value; + } +} diff --git a/src/modules/configuration/branches/dto/branch.dto.ts b/src/modules/configuration/branches/dto/branch.dto.ts new file mode 100644 index 0000000..12e5af0 --- /dev/null +++ b/src/modules/configuration/branches/dto/branch.dto.ts @@ -0,0 +1,340 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayNotEmpty, + IsArray, + IsIn, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + IsUUID, + Matches, + Max, + MaxLength, + Min, +} from 'class-validator'; +import { PaginationQueryDto } from '../../../../common/http/response'; +import { CORE_STATUSES } from '../../../../common/value-objects/status/status'; +import { + BRANCH_ADDRESS_MAX_LENGTH, + BRANCH_CODE_MAX_LENGTH, + BRANCH_CODE_PATTERN, + BRANCH_NAME_MAX_LENGTH, + BRANCH_NAME_PATTERN, + BRANCH_NFC_ID_MAX_LENGTH, + WEEKDAYS, + WORKING_HOURS_PATTERN, +} from '../branch-fields'; + +export class CreateBranchDto { + @ApiProperty({ example: 'JKT_01', maxLength: BRANCH_CODE_MAX_LENGTH }) + @IsString() + @IsNotEmpty() + @MaxLength(BRANCH_CODE_MAX_LENGTH) + @Matches(BRANCH_CODE_PATTERN, { + message: 'code must contain only letters, numbers, and underscores', + }) + code!: string; + + @ApiProperty({ example: 'Jakarta Pusat', maxLength: BRANCH_NAME_MAX_LENGTH }) + @IsString() + @IsNotEmpty() + @MaxLength(BRANCH_NAME_MAX_LENGTH) + @Matches(BRANCH_NAME_PATTERN, { + message: 'name must contain only letters and spaces', + }) + name!: string; + + @ApiProperty({ example: '+6281234567890' }) + @IsString() + @IsNotEmpty() + phone!: string; + + @ApiProperty({ example: 'Jl Sudirman No 1' }) + @IsString() + @IsNotEmpty() + @MaxLength(BRANCH_ADDRESS_MAX_LENGTH) + address!: string; + + @ApiPropertyOptional({ example: -6.2 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(-90) + @Max(90) + latitude?: number; + + @ApiPropertyOptional({ example: 106.8 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(-180) + @Max(180) + longitude?: number; + + @ApiProperty({ enum: WEEKDAYS, example: 'monday' }) + @IsIn([...WEEKDAYS]) + workingDaysStart!: string; + + @ApiProperty({ enum: WEEKDAYS, example: 'friday' }) + @IsIn([...WEEKDAYS]) + workingDaysEnd!: string; + + @ApiProperty({ example: '08:00' }) + @Matches(WORKING_HOURS_PATTERN, { + message: 'workingHoursStart must be HH:mm', + }) + workingHoursStart!: string; + + @ApiProperty({ example: '17:00' }) + @Matches(WORKING_HOURS_PATTERN, { message: 'workingHoursEnd must be HH:mm' }) + workingHoursEnd!: string; + + @ApiPropertyOptional({ example: 'NFC-001' }) + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(BRANCH_NFC_ID_MAX_LENGTH) + nfcId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID('4') + divisionId?: string; + + @ApiPropertyOptional({ enum: CORE_STATUSES }) + @IsOptional() + @IsIn([...CORE_STATUSES]) + status?: string; +} + +export class UpdateBranchDto { + @ApiPropertyOptional({ example: 'JKT_01' }) + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(BRANCH_CODE_MAX_LENGTH) + @Matches(BRANCH_CODE_PATTERN, { + message: 'code must contain only letters, numbers, and underscores', + }) + code?: string; + + @ApiPropertyOptional({ example: 'Jakarta Pusat' }) + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(BRANCH_NAME_MAX_LENGTH) + @Matches(BRANCH_NAME_PATTERN, { + message: 'name must contain only letters and spaces', + }) + name?: string; + + @ApiPropertyOptional({ example: '+6281234567890' }) + @IsOptional() + @IsString() + @IsNotEmpty() + phone?: string; + + @ApiPropertyOptional({ example: 'Jl Sudirman No 1' }) + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(BRANCH_ADDRESS_MAX_LENGTH) + address?: string; + + @ApiPropertyOptional({ example: -6.2, nullable: true }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(-90) + @Max(90) + latitude?: number | null; + + @ApiPropertyOptional({ example: 106.8, nullable: true }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(-180) + @Max(180) + longitude?: number | null; + + @ApiPropertyOptional({ enum: WEEKDAYS }) + @IsOptional() + @IsIn([...WEEKDAYS]) + workingDaysStart?: string; + + @ApiPropertyOptional({ enum: WEEKDAYS }) + @IsOptional() + @IsIn([...WEEKDAYS]) + workingDaysEnd?: string; + + @ApiPropertyOptional({ example: '08:00' }) + @IsOptional() + @Matches(WORKING_HOURS_PATTERN, { + message: 'workingHoursStart must be HH:mm', + }) + workingHoursStart?: string; + + @ApiPropertyOptional({ example: '17:00' }) + @IsOptional() + @Matches(WORKING_HOURS_PATTERN, { message: 'workingHoursEnd must be HH:mm' }) + workingHoursEnd?: string; + + @ApiPropertyOptional({ example: 'NFC-001', nullable: true }) + @IsOptional() + @IsString() + @MaxLength(BRANCH_NFC_ID_MAX_LENGTH) + nfcId?: string | null; + + @ApiPropertyOptional({ format: 'uuid', nullable: true }) + @IsOptional() + @IsUUID('4') + divisionId?: string | null; +} + +export class UpdateBranchStatusDto { + @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 ListBranchesQueryDto extends PaginationQueryDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + code?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + name?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + phone?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + address?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID('4') + divisionId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + nfcId?: string; + + @ApiPropertyOptional({ enum: CORE_STATUSES }) + @IsOptional() + @IsIn([...CORE_STATUSES]) + status?: string; + + @ApiPropertyOptional({ enum: WEEKDAYS }) + @IsOptional() + @IsIn([...WEEKDAYS]) + workingDaysStart?: string; + + @ApiPropertyOptional({ enum: WEEKDAYS }) + @IsOptional() + @IsIn([...WEEKDAYS]) + workingDaysEnd?: string; + + @ApiPropertyOptional() + @IsOptional() + @Matches(WORKING_HOURS_PATTERN) + workingHoursStart?: string; + + @ApiPropertyOptional() + @IsOptional() + @Matches(WORKING_HOURS_PATTERN) + workingHoursEnd?: string; + + @ApiPropertyOptional({ + description: 'Case-insensitive match on code, name, or address', + }) + @IsOptional() + @IsString() + search?: string; +} + +export class BranchDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty() + code!: string; + + @ApiProperty() + name!: string; + + @ApiProperty({ example: '+6281234567890' }) + phone!: string; + + @ApiProperty() + address!: string; + + @ApiPropertyOptional({ nullable: true }) + latitude!: number | null; + + @ApiPropertyOptional({ nullable: true }) + longitude!: number | null; + + @ApiProperty({ enum: WEEKDAYS }) + workingDaysStart!: string; + + @ApiProperty({ enum: WEEKDAYS }) + workingDaysEnd!: string; + + @ApiProperty() + workingHoursStart!: string; + + @ApiProperty() + workingHoursEnd!: string; + + @ApiPropertyOptional({ nullable: true }) + nfcId!: string | null; + + @ApiPropertyOptional({ format: 'uuid', nullable: true }) + divisionId!: string | null; + + @ApiProperty({ enum: CORE_STATUSES }) + status!: string; + + @ApiProperty({ description: 'Unix ms' }) + createdAt!: number; + + @ApiProperty({ description: 'Unix ms' }) + updatedAt!: number; + + @ApiProperty({ format: 'uuid' }) + createdBy!: string; + + @ApiProperty({ format: 'uuid' }) + updatedBy!: string; +} diff --git a/src/modules/configuration/configuration.module.ts b/src/modules/configuration/configuration.module.ts index 22afaab..1c98f17 100644 --- a/src/modules/configuration/configuration.module.ts +++ b/src/modules/configuration/configuration.module.ts @@ -1,8 +1,9 @@ import { Module } from '@nestjs/common'; +import { BranchesModule } from './branches/branches.module'; import { DivisionsModule } from './divisions/divisions.module'; @Module({ - imports: [DivisionsModule], - exports: [DivisionsModule], + imports: [DivisionsModule, BranchesModule], + exports: [DivisionsModule, BranchesModule], }) export class ConfigurationModule {} diff --git a/src/modules/configuration/divisions/divisions.repository.spec.ts b/src/modules/configuration/divisions/divisions.repository.spec.ts index 3c0c3c7..2985f3b 100644 --- a/src/modules/configuration/divisions/divisions.repository.spec.ts +++ b/src/modules/configuration/divisions/divisions.repository.spec.ts @@ -230,6 +230,13 @@ describe('DivisionsRepository', () => { ); }); + it('delete maps foreign-key violations to conflict', async () => { + returning.mockRejectedValueOnce({ code: '23503' }); + await expect(repository.delete('div-1')).rejects.toBeInstanceOf( + ConflictException, + ); + }); + it('bulkUpdateStatus and bulkDelete return 0 for empty ids', async () => { await expect( repository.bulkUpdateStatus([], Status.create('active'), 'user-1'), diff --git a/src/modules/configuration/divisions/divisions.repository.ts b/src/modules/configuration/divisions/divisions.repository.ts index 553d966..e58aa75 100644 --- a/src/modules/configuration/divisions/divisions.repository.ts +++ b/src/modules/configuration/divisions/divisions.repository.ts @@ -189,12 +189,16 @@ export class DivisionsRepository { } async delete(id: string): Promise { - const deleted = await this.db - .delete(divisions) - .where(eq(divisions.id, id)) - .returning({ id: divisions.id }); - if (deleted.length === 0) { - throw new NotFoundException('Division not found'); + try { + const deleted = await this.db + .delete(divisions) + .where(eq(divisions.id, id)) + .returning({ id: divisions.id }); + if (deleted.length === 0) { + throw new NotFoundException('Division not found'); + } + } catch (error) { + this.rethrowForeignKeyViolation(error); } } @@ -202,11 +206,15 @@ export class DivisionsRepository { if (ids.length === 0) { return 0; } - const deleted = await this.db - .delete(divisions) - .where(inArray(divisions.id, ids)) - .returning({ id: divisions.id }); - return deleted.length; + try { + const deleted = await this.db + .delete(divisions) + .where(inArray(divisions.id, ids)) + .returning({ id: divisions.id }); + return deleted.length; + } catch (error) { + this.rethrowForeignKeyViolation(error); + } } private buildListWhere(filters: ListDivisionsFilters): SQL | undefined { @@ -255,4 +263,12 @@ export class DivisionsRepository { } throw error; } + + private rethrowForeignKeyViolation(error: unknown): never { + const err = error as { code?: string }; + if (err.code === '23503') { + throw new ConflictException('Division is referenced by other records'); + } + throw error; + } } diff --git a/test/branches.e2e-spec.ts b/test/branches.e2e-spec.ts new file mode 100644 index 0000000..736d7f5 --- /dev/null +++ b/test/branches.e2e-spec.ts @@ -0,0 +1,244 @@ +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('Branches (e2e)', () => { + let app: INestApplication; + let db: DrizzleDB; + + const password = 'password123'; + const adminUsername = `br_admin_${Date.now()}`; + const otherUsername = `br_other_${Date.now()}`; + + let adminAccessToken: string; + let adminUserId: string; + let otherAccessToken: string; + let divisionId: string; + + const payload = { + code: `JKT_${Date.now().toString().slice(-6)}`, + name: 'Jakarta Pusat', + phone: '+6281234567890', + address: 'Jl Sudirman No 1', + workingDaysStart: 'monday', + workingDaysEnd: 'friday', + workingHoursStart: '08:00', + workingHoursEnd: '17:00', + }; + + 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: 'Branch Admin', + code: `BR_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)); + + const division = await request(app.getHttpServer()) + .post('/divisions') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ + name: 'Jakarta Division', + code: `JD_${Date.now().toString().slice(-6)}`, + }) + .expect(201); + divisionId = (division.body as { id: string }).id; + }); + + afterAll(async () => { + await app.close(); + }); + + it('forbids branches list without permission', async () => { + await request(app.getHttpServer()) + .get('/branches') + .set('Authorization', `Bearer ${otherAccessToken}`) + .expect(403); + }); + + it('rejects unauthenticated access', async () => { + await request(app.getHttpServer()).get('/branches').expect(401); + }); + + it('CRUD branches with phone, hours, optional geo/NFC/division, and bulk', async () => { + const created = await request(app.getHttpServer()) + .post('/branches') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send(payload) + .expect(201); + + expect(created.body).toMatchObject({ + code: payload.code, + name: 'Jakarta Pusat', + phone: '+6281234567890', + status: 'draft', + createdBy: adminUserId, + latitude: null, + nfcId: null, + divisionId: null, + }); + const id = (created.body as { id: string }).id; + + await request(app.getHttpServer()) + .post('/branches') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ ...payload, code: 'BAD 01', name: 'Other Branch' }) + .expect(400); + + await request(app.getHttpServer()) + .post('/branches') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ ...payload, code: 'PHN_01', phone: '081234567890' }) + .expect(400); + + await request(app.getHttpServer()) + .post('/branches') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ ...payload, code: 'DAY_01', workingDaysStart: 'Monday' }) + .expect(400); + + await request(app.getHttpServer()) + .get(`/branches/${id}`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .expect(200); + + await request(app.getHttpServer()) + .patch(`/branches/${id}`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ + name: 'Jakarta Selatan', + latitude: -6.2, + longitude: 106.8, + nfcId: 'NFC-001', + divisionId, + }) + .expect(200); + + await request(app.getHttpServer()) + .patch(`/branches/${id}`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ status: 'active' }) + .expect(400); + + await request(app.getHttpServer()) + .patch(`/branches/${id}/status`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ status: 'active' }) + .expect(200); + + const list = await request(app.getHttpServer()) + .get('/branches?search=Jakarta') + .set('Authorization', `Bearer ${adminAccessToken}`) + .expect(200); + expect( + (list.body as { data: unknown[] }).data.length, + ).toBeGreaterThanOrEqual(1); + expect((list.body as { meta?: unknown }).meta).toBeDefined(); + + const extra = await request(app.getHttpServer()) + .post('/branches') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ + ...payload, + code: `BDG_${Date.now().toString().slice(-6)}`, + name: 'Bandung Kota', + }) + .expect(201); + + await request(app.getHttpServer()) + .post('/branches/bulk-status') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ ids: [(extra.body as { id: string }).id], status: 'archived' }) + .expect(200); + + await request(app.getHttpServer()) + .post('/branches/bulk-delete') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ ids: [(extra.body as { id: string }).id] }) + .expect(200); + + await request(app.getHttpServer()) + .delete(`/branches/${id}`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .expect(204); + }); + + it('imports branches from CSV', async () => { + const suffix = Date.now().toString().slice(-6); + const csv = + 'code,name,phone,address,workingDaysStart,workingDaysEnd,workingHoursStart,workingHoursEnd,status\n' + + `IMP_${suffix},Imported Branch,+6281234567890,Jl Imported No 1,monday,friday,08:00,17:00,draft\n`; + const res = await request(app.getHttpServer()) + .post('/branches/import') + .set('Authorization', `Bearer ${adminAccessToken}`) + .attach('file', Buffer.from(csv, 'utf8'), 'branches.csv') + .expect(201); + + expect(res.body as { imported: number }).toMatchObject({ imported: 1 }); + }); +});