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.
This commit is contained in:
@@ -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);
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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')
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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<PaginationResponse<BranchDto>> {
|
||||
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<BranchDto> {
|
||||
return this.branchesService.findById(id);
|
||||
}
|
||||
}
|
||||
|
||||
void PaginationMetaDto;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<BranchDto> {
|
||||
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<BranchDto> {
|
||||
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<BranchDto> {
|
||||
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<void> {
|
||||
await this.branchesService.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<void>) => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<T>(qb: T, filters: ListBranchesFilters): T {
|
||||
void filters;
|
||||
return qb;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Branch | null> {
|
||||
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<Branch | null> {
|
||||
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<Branch> {
|
||||
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<number> {
|
||||
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<Branch> {
|
||||
const existing = await this.findById(id);
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Branch not found');
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
try {
|
||||
const values: Partial<NewBranchRow> = {
|
||||
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<Branch> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<PaginationResponse<ReturnType<BranchesService['toListItem']>>> {
|
||||
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<ReturnType<BranchesService['toListItem']>> {
|
||||
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<ReturnType<BranchesService['toListItem']>> {
|
||||
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<ReturnType<BranchesService['toListItem']>> {
|
||||
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<ReturnType<BranchesService['toListItem']>> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -189,6 +189,7 @@ export class DivisionsRepository {
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
try {
|
||||
const deleted = await this.db
|
||||
.delete(divisions)
|
||||
.where(eq(divisions.id, id))
|
||||
@@ -196,17 +197,24 @@ export class DivisionsRepository {
|
||||
if (deleted.length === 0) {
|
||||
throw new NotFoundException('Division not found');
|
||||
}
|
||||
} catch (error) {
|
||||
this.rethrowForeignKeyViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async bulkDelete(ids: string[]): Promise<number> {
|
||||
if (ids.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<App>;
|
||||
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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user