Add divisions management module with database schema and validation
- Introduced `DivisionsModule` to manage organizational divisions, including read and write controllers. - Created database migrations for the `divisions` table and related constraints. - Implemented validation for division name and code with corresponding utility functions. - Added service and repository layers for handling division data operations. - Developed unit tests for the divisions service, repository, and controllers to ensure functionality and correctness. - Updated application module to include the new `ConfigurationModule` for better organization.
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
CREATE TABLE "divisions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"name" varchar(64) NOT NULL,
|
||||||
|
"code" varchar(16) NOT NULL,
|
||||||
|
"status" text DEFAULT 'draft' NOT NULL,
|
||||||
|
"created_at" bigint NOT NULL,
|
||||||
|
"updated_at" bigint NOT NULL,
|
||||||
|
"created_by" uuid NOT NULL,
|
||||||
|
"updated_by" uuid NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "divisions" ADD CONSTRAINT "divisions_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 "divisions" ADD CONSTRAINT "divisions_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 "divisions_code_unique" ON "divisions" USING btree ("code");
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES
|
||||||
|
('CONFIGURATION.DIVISION', 'Divisions', 3);
|
||||||
@@ -0,0 +1,619 @@
|
|||||||
|
{
|
||||||
|
"id": "08391e1e-5713-4662-8622-818ec5ab33e7",
|
||||||
|
"prevId": "892b17dc-a719-40eb-a6e1-ac43663d4f3c",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,13 @@
|
|||||||
"when": 1787544143116,
|
"when": 1787544143116,
|
||||||
"tag": "0003_pretty_darkhawk",
|
"tag": "0003_pretty_darkhawk",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787546862278,
|
||||||
|
"tag": "0004_next_the_fury",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import { AppService } from './app.service';
|
|||||||
import loadEnv from './config/env';
|
import loadEnv from './config/env';
|
||||||
import { DatabaseModule } from './database/database.module';
|
import { DatabaseModule } from './database/database.module';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
|
import { ConfigurationModule } from './modules/configuration/configuration.module';
|
||||||
import { PrivilegesModule } from './modules/privileges/privileges.module';
|
import { PrivilegesModule } from './modules/privileges/privileges.module';
|
||||||
import { UsersModule } from './modules/users/users.module';
|
import { UsersModule } from './modules/users/users.module';
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ import { UsersModule } from './modules/users/users.module';
|
|||||||
UsersModule,
|
UsersModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
PrivilegesModule,
|
PrivilegesModule,
|
||||||
|
ConfigurationModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
text,
|
text,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
uuid,
|
uuid,
|
||||||
|
varchar,
|
||||||
} from 'drizzle-orm/pg-core';
|
} from 'drizzle-orm/pg-core';
|
||||||
import { primaryEntityColumns } from './primary-entity-columns';
|
import { primaryEntityColumns } from './primary-entity-columns';
|
||||||
|
|
||||||
@@ -117,6 +118,20 @@ export const privilegeDetails = pgTable(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Organizational divisions (primary aggregate).
|
||||||
|
*/
|
||||||
|
export const divisions = pgTable(
|
||||||
|
'divisions',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||||
|
name: varchar('name', { length: 64 }).notNull(),
|
||||||
|
code: varchar('code', { length: 16 }).notNull(),
|
||||||
|
...primaryEntityColumns(users),
|
||||||
|
},
|
||||||
|
(t) => [uniqueIndex('divisions_code_unique').on(t.code)],
|
||||||
|
);
|
||||||
|
|
||||||
export type UserRow = typeof users.$inferSelect;
|
export type UserRow = typeof users.$inferSelect;
|
||||||
export type NewUserRow = typeof users.$inferInsert;
|
export type NewUserRow = typeof users.$inferInsert;
|
||||||
export type RefreshTokenRow = typeof refreshTokens.$inferSelect;
|
export type RefreshTokenRow = typeof refreshTokens.$inferSelect;
|
||||||
@@ -124,3 +139,5 @@ export type NewRefreshTokenRow = typeof refreshTokens.$inferInsert;
|
|||||||
export type PrivilegeKeyRow = typeof privilegeKeys.$inferSelect;
|
export type PrivilegeKeyRow = typeof privilegeKeys.$inferSelect;
|
||||||
export type PrivilegeRow = typeof privileges.$inferSelect;
|
export type PrivilegeRow = typeof privileges.$inferSelect;
|
||||||
export type PrivilegeDetailRow = typeof privilegeDetails.$inferSelect;
|
export type PrivilegeDetailRow = typeof privilegeDetails.$inferSelect;
|
||||||
|
export type DivisionRow = typeof divisions.$inferSelect;
|
||||||
|
export type NewDivisionRow = typeof divisions.$inferInsert;
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DivisionsModule } from './divisions/divisions.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [DivisionsModule],
|
||||||
|
exports: [DivisionsModule],
|
||||||
|
})
|
||||||
|
export class ConfigurationModule {}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import {
|
||||||
|
DIVISION_CODE_MAX_LENGTH,
|
||||||
|
DIVISION_NAME_MAX_LENGTH,
|
||||||
|
isValidDivisionCode,
|
||||||
|
isValidDivisionName,
|
||||||
|
} from './division-fields';
|
||||||
|
|
||||||
|
describe('division fields', () => {
|
||||||
|
describe('isValidDivisionName', () => {
|
||||||
|
it.each(['Finance', 'Human Resources', 'A', 'North West Region'])(
|
||||||
|
'accepts %s',
|
||||||
|
(name) => {
|
||||||
|
expect(isValidDivisionName(name)).toBe(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'',
|
||||||
|
'Finance1',
|
||||||
|
'Human-Resources',
|
||||||
|
'HR_OPS',
|
||||||
|
' Finance',
|
||||||
|
'Finance ',
|
||||||
|
'Human Resources',
|
||||||
|
'财务',
|
||||||
|
])('rejects %s', (name) => {
|
||||||
|
expect(isValidDivisionName(name)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects names longer than 64 characters', () => {
|
||||||
|
expect(
|
||||||
|
isValidDivisionName('A'.repeat(DIVISION_NAME_MAX_LENGTH + 1)),
|
||||||
|
).toBe(false);
|
||||||
|
expect(isValidDivisionName('A'.repeat(DIVISION_NAME_MAX_LENGTH))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isValidDivisionCode', () => {
|
||||||
|
it.each(['FIN', 'FIN_01', 'A', 'ops2', 'A_b_1'])('accepts %s', (code) => {
|
||||||
|
expect(isValidDivisionCode(code)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['', 'FIN 01', 'FIN-01', 'FIN.01', ' FIN', 'FIN '])(
|
||||||
|
'rejects %s',
|
||||||
|
(code) => {
|
||||||
|
expect(isValidDivisionCode(code)).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('rejects codes longer than 16 characters', () => {
|
||||||
|
expect(
|
||||||
|
isValidDivisionCode('A'.repeat(DIVISION_CODE_MAX_LENGTH + 1)),
|
||||||
|
).toBe(false);
|
||||||
|
expect(isValidDivisionCode('A'.repeat(DIVISION_CODE_MAX_LENGTH))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export const DIVISION_NAME_MAX_LENGTH = 64;
|
||||||
|
export const DIVISION_CODE_MAX_LENGTH = 16;
|
||||||
|
|
||||||
|
/** Letters with single spaces between words. */
|
||||||
|
export const DIVISION_NAME_PATTERN = /^[A-Za-z]+(?: [A-Za-z]+)*$/;
|
||||||
|
|
||||||
|
/** Alphanumeric and underscore; no spaces. */
|
||||||
|
export const DIVISION_CODE_PATTERN = /^[A-Za-z0-9_]+$/;
|
||||||
|
|
||||||
|
export function isValidDivisionName(raw: string): boolean {
|
||||||
|
return (
|
||||||
|
typeof raw === 'string' &&
|
||||||
|
raw.length > 0 &&
|
||||||
|
raw.length <= DIVISION_NAME_MAX_LENGTH &&
|
||||||
|
DIVISION_NAME_PATTERN.test(raw)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidDivisionCode(raw: string): boolean {
|
||||||
|
return (
|
||||||
|
typeof raw === 'string' &&
|
||||||
|
raw.length > 0 &&
|
||||||
|
raw.length <= DIVISION_CODE_MAX_LENGTH &&
|
||||||
|
DIVISION_CODE_PATTERN.test(raw)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
|
|
||||||
|
export type Division = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly code: string;
|
||||||
|
readonly status: Status;
|
||||||
|
readonly createdAt: DateTime;
|
||||||
|
readonly updatedAt: DateTime;
|
||||||
|
readonly createdBy: string;
|
||||||
|
readonly updatedBy: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateDivisionInput = {
|
||||||
|
readonly name: string;
|
||||||
|
readonly code: string;
|
||||||
|
readonly status?: Status;
|
||||||
|
readonly userId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateDivisionInput = {
|
||||||
|
readonly name?: string;
|
||||||
|
readonly code?: string;
|
||||||
|
readonly userId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListDivisionsFilters = {
|
||||||
|
readonly name?: string;
|
||||||
|
readonly code?: string;
|
||||||
|
readonly status?: string;
|
||||||
|
readonly search?: string;
|
||||||
|
readonly limit: number;
|
||||||
|
readonly offset: number;
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { DivisionsReadController } from './divisions-read.controller';
|
||||||
|
import { DivisionsService } from './divisions.service';
|
||||||
|
|
||||||
|
describe('DivisionsReadController', () => {
|
||||||
|
let controller: DivisionsReadController;
|
||||||
|
const service = {
|
||||||
|
list: jest.fn(),
|
||||||
|
findById: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [DivisionsReadController],
|
||||||
|
providers: [{ provide: DivisionsService, useValue: service }],
|
||||||
|
}).compile();
|
||||||
|
controller = moduleRef.get(DivisionsReadController);
|
||||||
|
});
|
||||||
|
|
||||||
|
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: 'div-1' });
|
||||||
|
await expect(controller.findOne('div-1')).resolves.toEqual({ id: 'div-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 { DivisionDto, ListDivisionsQueryDto } from './dto/division.dto';
|
||||||
|
import { DivisionsService } from './divisions.service';
|
||||||
|
|
||||||
|
export const DIVISION_PRIVILEGE_KEY = 'CONFIGURATION.DIVISION';
|
||||||
|
|
||||||
|
@ApiTags('divisions')
|
||||||
|
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||||
|
@Controller('divisions')
|
||||||
|
export class DivisionsReadController {
|
||||||
|
constructor(private readonly divisionsService: DivisionsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Pagination()
|
||||||
|
@RequirePrivilege(DIVISION_PRIVILEGE_KEY, 'view')
|
||||||
|
@ApiOperation({ summary: 'List divisions' })
|
||||||
|
@ApiOkResponse({
|
||||||
|
schema: {
|
||||||
|
properties: {
|
||||||
|
data: {
|
||||||
|
type: 'array',
|
||||||
|
items: { $ref: '#/components/schemas/DivisionDto' },
|
||||||
|
},
|
||||||
|
meta: { $ref: '#/components/schemas/PaginationMetaDto' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
list(
|
||||||
|
@Query() query: ListDivisionsQueryDto,
|
||||||
|
): Promise<PaginationResponse<DivisionDto>> {
|
||||||
|
return this.divisionsService.list(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePrivilege(DIVISION_PRIVILEGE_KEY, 'view')
|
||||||
|
@ApiOperation({ summary: 'Get division detail' })
|
||||||
|
@ApiOkResponse({ type: DivisionDto })
|
||||||
|
@ApiNotFoundResponse()
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<DivisionDto> {
|
||||||
|
return this.divisionsService.findById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PaginationMetaDto;
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { DivisionsWriteController } from './divisions-write.controller';
|
||||||
|
import { DivisionsService } from './divisions.service';
|
||||||
|
|
||||||
|
describe('DivisionsWriteController', () => {
|
||||||
|
let controller: DivisionsWriteController;
|
||||||
|
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: [DivisionsWriteController],
|
||||||
|
providers: [{ provide: DivisionsService, useValue: service }],
|
||||||
|
}).compile();
|
||||||
|
controller = moduleRef.get(DivisionsWriteController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create passes dto fields and user id', async () => {
|
||||||
|
service.create.mockResolvedValue({ id: 'div-1' });
|
||||||
|
await controller.create({ name: 'Finance', code: 'FIN' }, 'user-1');
|
||||||
|
expect(service.create).toHaveBeenCalledWith({
|
||||||
|
name: 'Finance',
|
||||||
|
code: 'FIN',
|
||||||
|
status: undefined,
|
||||||
|
userId: 'user-1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update, updateStatus, and delete delegate', async () => {
|
||||||
|
service.update.mockResolvedValue({ id: 'div-1' });
|
||||||
|
service.updateStatus.mockResolvedValue({ id: 'div-1' });
|
||||||
|
service.delete.mockResolvedValue(undefined);
|
||||||
|
await controller.update('div-1', { name: 'Finance' }, 'user-1');
|
||||||
|
await controller.updateStatus('div-1', { status: 'active' }, 'user-1');
|
||||||
|
await controller.delete('div-1');
|
||||||
|
expect(service.update).toHaveBeenCalled();
|
||||||
|
expect(service.updateStatus).toHaveBeenCalledWith(
|
||||||
|
'div-1',
|
||||||
|
'active',
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
expect(service.delete).toHaveBeenCalledWith('div-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: ['div-1'] });
|
||||||
|
await controller.bulkStatus(
|
||||||
|
{ ids: ['div-1'], status: 'archived' },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
await controller.importCsv(
|
||||||
|
{ buffer: Buffer.from('name,code\nA,A') },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
expect(service.importCsv).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
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,
|
||||||
|
CreateDivisionDto,
|
||||||
|
DivisionDto,
|
||||||
|
UpdateDivisionDto,
|
||||||
|
UpdateDivisionStatusDto,
|
||||||
|
} from './dto/division.dto';
|
||||||
|
import { DIVISION_PRIVILEGE_KEY } from './divisions-read.controller';
|
||||||
|
import { DivisionsService } from './divisions.service';
|
||||||
|
|
||||||
|
@ApiTags('divisions')
|
||||||
|
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||||
|
@Controller('divisions')
|
||||||
|
export class DivisionsWriteController {
|
||||||
|
constructor(private readonly divisionsService: DivisionsService) {}
|
||||||
|
|
||||||
|
@Post('import')
|
||||||
|
@RequirePrivilege(DIVISION_PRIVILEGE_KEY, 'import')
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor('file', {
|
||||||
|
limits: { fileSize: 1_048_576 },
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
if (
|
||||||
|
!file.mimetype.includes('csv') &&
|
||||||
|
!file.originalname.toLowerCase().endsWith('.csv')
|
||||||
|
) {
|
||||||
|
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 divisions 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.divisionsService.importCsv(csv, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bulk-delete')
|
||||||
|
@HttpCode(200)
|
||||||
|
@RequirePrivilege(DIVISION_PRIVILEGE_KEY, 'delete')
|
||||||
|
@ApiOperation({ summary: 'Bulk delete divisions' })
|
||||||
|
@ApiOkResponse({
|
||||||
|
schema: { properties: { deleted: { type: 'number' } } },
|
||||||
|
})
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
bulkDelete(@Body() dto: BulkIdsDto): Promise<{ deleted: number }> {
|
||||||
|
return this.divisionsService.bulkDelete(dto.ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bulk-status')
|
||||||
|
@HttpCode(200)
|
||||||
|
@RequirePrivilege(DIVISION_PRIVILEGE_KEY, 'update')
|
||||||
|
@ApiOperation({ summary: 'Bulk update division status' })
|
||||||
|
@ApiOkResponse({
|
||||||
|
schema: { properties: { updated: { type: 'number' } } },
|
||||||
|
})
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
bulkStatus(
|
||||||
|
@Body() dto: BulkStatusDto,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
): Promise<{ updated: number }> {
|
||||||
|
return this.divisionsService.bulkUpdateStatus(dto.ids, dto.status, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePrivilege(DIVISION_PRIVILEGE_KEY, 'create')
|
||||||
|
@ApiOperation({ summary: 'Create division' })
|
||||||
|
@ApiCreatedResponse({ type: DivisionDto })
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
create(
|
||||||
|
@Body() dto: CreateDivisionDto,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
): Promise<DivisionDto> {
|
||||||
|
return this.divisionsService.create({
|
||||||
|
name: dto.name,
|
||||||
|
code: dto.code,
|
||||||
|
status: dto.status,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/status')
|
||||||
|
@RequirePrivilege(DIVISION_PRIVILEGE_KEY, 'update')
|
||||||
|
@ApiOperation({ summary: 'Update division status' })
|
||||||
|
@ApiOkResponse({ type: DivisionDto })
|
||||||
|
@ApiNotFoundResponse()
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
updateStatus(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: UpdateDivisionStatusDto,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
): Promise<DivisionDto> {
|
||||||
|
return this.divisionsService.updateStatus(id, dto.status, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePrivilege(DIVISION_PRIVILEGE_KEY, 'update')
|
||||||
|
@ApiOperation({ summary: 'Update division (not status)' })
|
||||||
|
@ApiOkResponse({ type: DivisionDto })
|
||||||
|
@ApiNotFoundResponse()
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: UpdateDivisionDto,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
): Promise<DivisionDto> {
|
||||||
|
return this.divisionsService.update(id, {
|
||||||
|
name: dto.name,
|
||||||
|
code: dto.code,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HttpCode(204)
|
||||||
|
@RequirePrivilege(DIVISION_PRIVILEGE_KEY, 'delete')
|
||||||
|
@ApiOperation({ summary: 'Delete division' })
|
||||||
|
@ApiNoContentResponse()
|
||||||
|
@ApiNotFoundResponse()
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
async delete(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||||
|
await this.divisionsService.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DivisionsReadController } from './divisions-read.controller';
|
||||||
|
import { DivisionsWriteController } from './divisions-write.controller';
|
||||||
|
import { DivisionsRepository } from './divisions.repository';
|
||||||
|
import { DivisionsService } from './divisions.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [DivisionsReadController, DivisionsWriteController],
|
||||||
|
providers: [DivisionsRepository, DivisionsService],
|
||||||
|
exports: [DivisionsService],
|
||||||
|
})
|
||||||
|
export class DivisionsModule {}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
|
import { DRIZZLE } from '../../../database/database.module';
|
||||||
|
import { DivisionsRepository } from './divisions.repository';
|
||||||
|
|
||||||
|
describe('DivisionsRepository', () => {
|
||||||
|
let repository: DivisionsRepository;
|
||||||
|
|
||||||
|
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: 'div-1',
|
||||||
|
name: 'Finance',
|
||||||
|
code: 'FIN',
|
||||||
|
status: 'draft',
|
||||||
|
createdAt: 1_700_000_000_000,
|
||||||
|
updatedAt: 1_700_000_000_000,
|
||||||
|
createdBy: 'user-1',
|
||||||
|
updatedBy: '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: [DivisionsRepository, { provide: DRIZZLE, useValue: db }],
|
||||||
|
}).compile();
|
||||||
|
repository = moduleRef.get(DivisionsRepository);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findById maps a row to domain Division', async () => {
|
||||||
|
limit.mockResolvedValueOnce([row]);
|
||||||
|
const division = await repository.findById('div-1');
|
||||||
|
expect(division).toMatchObject({
|
||||||
|
id: 'div-1',
|
||||||
|
name: 'Finance',
|
||||||
|
code: 'FIN',
|
||||||
|
createdBy: 'user-1',
|
||||||
|
});
|
||||||
|
expect(division?.status.value).toBe('draft');
|
||||||
|
expect(division?.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 division = await repository.findByCode('FIN');
|
||||||
|
expect(division?.code).toBe('FIN');
|
||||||
|
});
|
||||||
|
|
||||||
|
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: 'Fin',
|
||||||
|
code: 'FIN',
|
||||||
|
status: 'draft',
|
||||||
|
search: 'fin',
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
expect(result.total).toBe(1);
|
||||||
|
expect(result.data[0].code).toBe('FIN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create inserts and maps unique violations', async () => {
|
||||||
|
returning.mockResolvedValueOnce([row]);
|
||||||
|
const created = await repository.create({
|
||||||
|
name: 'Finance',
|
||||||
|
code: 'FIN',
|
||||||
|
userId: 'user-1',
|
||||||
|
});
|
||||||
|
expect(created.code).toBe('FIN');
|
||||||
|
|
||||||
|
returning.mockRejectedValueOnce({ code: '23505' });
|
||||||
|
await expect(
|
||||||
|
repository.create({ name: 'Finance', code: 'FIN', userId: 'user-1' }),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create rethrows unknown errors', async () => {
|
||||||
|
returning.mockRejectedValue(new Error('db down'));
|
||||||
|
await expect(
|
||||||
|
repository.create({ name: 'Finance', code: 'FIN', userId: 'user-1' }),
|
||||||
|
).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([
|
||||||
|
{ name: 'Finance', code: 'FIN', userId: 'user-1' },
|
||||||
|
]),
|
||||||
|
).resolves.toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createMany maps unique violations', async () => {
|
||||||
|
transaction.mockRejectedValue({ code: '23505' });
|
||||||
|
await expect(
|
||||||
|
repository.createMany([
|
||||||
|
{ name: 'Finance', code: 'FIN', userId: 'user-1' },
|
||||||
|
]),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateStatus returns the mapped row', async () => {
|
||||||
|
returning.mockResolvedValueOnce([row]);
|
||||||
|
const updated = await repository.updateStatus(
|
||||||
|
'div-1',
|
||||||
|
Status.create('active'),
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
expect(updated.id).toBe('div-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('list without filters still returns data', async () => {
|
||||||
|
select
|
||||||
|
.mockImplementationOnce(() => ({
|
||||||
|
from: () => ({
|
||||||
|
where: () => Promise.resolve([{ total: 0 }]),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
.mockImplementationOnce(() => ({
|
||||||
|
from: () => ({
|
||||||
|
$dynamic: () => ({
|
||||||
|
where: () => ({
|
||||||
|
orderBy: () => ({
|
||||||
|
limit: () => ({
|
||||||
|
offset: () => Promise.resolve([]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
const result = await repository.list({ limit: 10, offset: 0 });
|
||||||
|
expect(result).toEqual({ data: [], total: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update throws when missing and maps unique violations', async () => {
|
||||||
|
limit.mockResolvedValueOnce([]);
|
||||||
|
await expect(
|
||||||
|
repository.update('missing', { userId: 'user-1' }),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
|
||||||
|
limit.mockResolvedValueOnce([row]);
|
||||||
|
returning.mockRejectedValueOnce({ code: '23505' });
|
||||||
|
await expect(
|
||||||
|
repository.update('div-1', { code: 'FIN2', userId: 'user-1' }),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
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: 'div-1' }, { id: 'div-2' }]);
|
||||||
|
await expect(
|
||||||
|
repository.bulkUpdateStatus(
|
||||||
|
['div-1', 'div-2'],
|
||||||
|
Status.create('active'),
|
||||||
|
'user-1',
|
||||||
|
),
|
||||||
|
).resolves.toBe(2);
|
||||||
|
returning.mockResolvedValue([{ id: 'div-1' }]);
|
||||||
|
await expect(repository.bulkDelete(['div-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,258 @@
|
|||||||
|
import {
|
||||||
|
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 { Status } from '../../../common/value-objects/status/status';
|
||||||
|
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||||
|
import { divisions, type DivisionRow } from '../../../database/schema';
|
||||||
|
import type {
|
||||||
|
CreateDivisionInput,
|
||||||
|
Division,
|
||||||
|
ListDivisionsFilters,
|
||||||
|
UpdateDivisionInput,
|
||||||
|
} from './division';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DivisionsRepository {
|
||||||
|
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||||
|
|
||||||
|
async list(
|
||||||
|
filters: ListDivisionsFilters,
|
||||||
|
): Promise<{ data: Division[]; total: number }> {
|
||||||
|
const where = this.buildListWhere(filters);
|
||||||
|
const totalRows = await this.db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(divisions)
|
||||||
|
.where(where);
|
||||||
|
const totalRow = totalRows[0];
|
||||||
|
|
||||||
|
let qb = this.db.select().from(divisions).$dynamic();
|
||||||
|
qb = this.extendListQuery(qb, filters);
|
||||||
|
const rows = await qb
|
||||||
|
.where(where)
|
||||||
|
.orderBy(asc(divisions.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: ListDivisionsFilters): T {
|
||||||
|
void filters;
|
||||||
|
return qb;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<Division | null> {
|
||||||
|
const rows: DivisionRow[] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(divisions)
|
||||||
|
.where(eq(divisions.id, id))
|
||||||
|
.limit(1);
|
||||||
|
const row = rows[0];
|
||||||
|
return row ? this.toDomain(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByCode(code: string): Promise<Division | null> {
|
||||||
|
const rows = await this.db
|
||||||
|
.select()
|
||||||
|
.from(divisions)
|
||||||
|
.where(eq(divisions.code, code))
|
||||||
|
.limit(1);
|
||||||
|
const row = rows[0];
|
||||||
|
return row ? this.toDomain(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateDivisionInput): Promise<Division> {
|
||||||
|
const now = DateTime.fromUnixMs(Date.now());
|
||||||
|
const status = input.status ?? Status.create(Status.DEFAULT);
|
||||||
|
try {
|
||||||
|
const inserted = await this.db
|
||||||
|
.insert(divisions)
|
||||||
|
.values({
|
||||||
|
name: input.name,
|
||||||
|
code: input.code,
|
||||||
|
status: status.value,
|
||||||
|
createdAt: now.value,
|
||||||
|
updatedAt: now.value,
|
||||||
|
createdBy: input.userId,
|
||||||
|
updatedBy: input.userId,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
const row = inserted[0];
|
||||||
|
return this.toDomain(row);
|
||||||
|
} catch (error) {
|
||||||
|
this.rethrowUniqueViolation(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMany(inputs: CreateDivisionInput[]): 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(divisions).values({
|
||||||
|
name: input.name,
|
||||||
|
code: input.code,
|
||||||
|
status: status.value,
|
||||||
|
createdAt: now.value,
|
||||||
|
updatedAt: now.value,
|
||||||
|
createdBy: input.userId,
|
||||||
|
updatedBy: input.userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return inputs.length;
|
||||||
|
} catch (error) {
|
||||||
|
this.rethrowUniqueViolation(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, input: UpdateDivisionInput): Promise<Division> {
|
||||||
|
const existing = await this.findById(id);
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('Division not found');
|
||||||
|
}
|
||||||
|
const now = DateTime.fromUnixMs(Date.now());
|
||||||
|
try {
|
||||||
|
const updated = await this.db
|
||||||
|
.update(divisions)
|
||||||
|
.set({
|
||||||
|
name: input.name ?? existing.name,
|
||||||
|
code: input.code ?? existing.code,
|
||||||
|
updatedAt: now.value,
|
||||||
|
updatedBy: input.userId,
|
||||||
|
})
|
||||||
|
.where(eq(divisions.id, id))
|
||||||
|
.returning();
|
||||||
|
const row = updated[0];
|
||||||
|
return this.toDomain(row);
|
||||||
|
} catch (error) {
|
||||||
|
this.rethrowUniqueViolation(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(
|
||||||
|
id: string,
|
||||||
|
status: Status,
|
||||||
|
userId: string,
|
||||||
|
): Promise<Division> {
|
||||||
|
const now = DateTime.fromUnixMs(Date.now());
|
||||||
|
const updated = await this.db
|
||||||
|
.update(divisions)
|
||||||
|
.set({
|
||||||
|
status: status.value,
|
||||||
|
updatedAt: now.value,
|
||||||
|
updatedBy: userId,
|
||||||
|
})
|
||||||
|
.where(eq(divisions.id, id))
|
||||||
|
.returning();
|
||||||
|
const row = updated[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new NotFoundException('Division 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(divisions)
|
||||||
|
.set({
|
||||||
|
status: status.value,
|
||||||
|
updatedAt: now.value,
|
||||||
|
updatedBy: userId,
|
||||||
|
})
|
||||||
|
.where(inArray(divisions.id, ids))
|
||||||
|
.returning({ id: divisions.id });
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
const deleted = await this.db
|
||||||
|
.delete(divisions)
|
||||||
|
.where(eq(divisions.id, id))
|
||||||
|
.returning({ id: divisions.id });
|
||||||
|
if (deleted.length === 0) {
|
||||||
|
throw new NotFoundException('Division not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async bulkDelete(ids: string[]): Promise<number> {
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const deleted = await this.db
|
||||||
|
.delete(divisions)
|
||||||
|
.where(inArray(divisions.id, ids))
|
||||||
|
.returning({ id: divisions.id });
|
||||||
|
return deleted.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildListWhere(filters: ListDivisionsFilters): SQL | undefined {
|
||||||
|
const parts: SQL[] = [];
|
||||||
|
if (filters.name) {
|
||||||
|
parts.push(ilike(divisions.name, `%${filters.name}%`));
|
||||||
|
}
|
||||||
|
if (filters.code) {
|
||||||
|
parts.push(ilike(divisions.code, `%${filters.code}%`));
|
||||||
|
}
|
||||||
|
if (filters.status) {
|
||||||
|
parts.push(eq(divisions.status, filters.status));
|
||||||
|
}
|
||||||
|
if (filters.search) {
|
||||||
|
const search = or(
|
||||||
|
ilike(divisions.name, `%${filters.search}%`),
|
||||||
|
ilike(divisions.code, `%${filters.search}%`),
|
||||||
|
);
|
||||||
|
if (search) {
|
||||||
|
parts.push(search);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return parts.length === 1 ? parts[0] : and(...parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDomain(row: DivisionRow): Division {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
code: row.code,
|
||||||
|
status: Status.create(row.status),
|
||||||
|
createdAt: DateTime.fromUnixMs(row.createdAt),
|
||||||
|
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||||
|
createdBy: row.createdBy,
|
||||||
|
updatedBy: row.updatedBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private rethrowUniqueViolation(error: unknown): never {
|
||||||
|
const err = error as { code?: string };
|
||||||
|
if (err.code === '23505') {
|
||||||
|
throw new ConflictException('Division code already exists');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
|
import type { Division } from './division';
|
||||||
|
import { DivisionsRepository } from './divisions.repository';
|
||||||
|
import { DivisionsService } from './divisions.service';
|
||||||
|
|
||||||
|
describe('DivisionsService', () => {
|
||||||
|
let service: DivisionsService;
|
||||||
|
let repository: jest.Mocked<
|
||||||
|
Pick<
|
||||||
|
DivisionsRepository,
|
||||||
|
| 'list'
|
||||||
|
| 'findById'
|
||||||
|
| 'create'
|
||||||
|
| 'createMany'
|
||||||
|
| 'update'
|
||||||
|
| 'updateStatus'
|
||||||
|
| 'bulkUpdateStatus'
|
||||||
|
| 'delete'
|
||||||
|
| 'bulkDelete'
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const now = DateTime.fromUnixMs(1_700_000_000_000);
|
||||||
|
const sample: Division = {
|
||||||
|
id: 'div-1',
|
||||||
|
name: 'Finance',
|
||||||
|
code: 'FIN',
|
||||||
|
status: Status.create('draft'),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
createdBy: 'user-1',
|
||||||
|
updatedBy: '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: [
|
||||||
|
DivisionsService,
|
||||||
|
{ provide: DivisionsRepository, useValue: repository },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = moduleRef.get(DivisionsService);
|
||||||
|
});
|
||||||
|
|
||||||
|
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: 'div-1',
|
||||||
|
name: 'Finance',
|
||||||
|
code: 'FIN',
|
||||||
|
status: 'draft',
|
||||||
|
createdAt: now.value,
|
||||||
|
});
|
||||||
|
expect(service.visibleFields).toContain('status');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findById throws when missing', async () => {
|
||||||
|
repository.findById.mockResolvedValue(null);
|
||||||
|
await expect(service.findById('missing')).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create defaults status to draft and trims fields', async () => {
|
||||||
|
repository.create.mockResolvedValue(sample);
|
||||||
|
await service.create({
|
||||||
|
name: 'Finance',
|
||||||
|
code: 'FIN',
|
||||||
|
userId: 'user-1',
|
||||||
|
});
|
||||||
|
expect(repository.create).toHaveBeenCalled();
|
||||||
|
const arg = repository.create.mock.calls[0][0];
|
||||||
|
expect(arg.status?.value).toBe('draft');
|
||||||
|
expect(arg.name).toBe('Finance');
|
||||||
|
expect(arg.code).toBe('FIN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create uses provided status', async () => {
|
||||||
|
repository.create.mockResolvedValue(sample);
|
||||||
|
await service.create({
|
||||||
|
name: 'Finance',
|
||||||
|
code: 'FIN',
|
||||||
|
status: 'active',
|
||||||
|
userId: 'user-1',
|
||||||
|
});
|
||||||
|
const arg = repository.create.mock.calls[0][0];
|
||||||
|
expect(arg.status?.value).toBe('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create rejects invalid name', async () => {
|
||||||
|
await expect(
|
||||||
|
service.create({
|
||||||
|
name: 'Finance1',
|
||||||
|
code: 'FIN',
|
||||||
|
userId: 'user-1',
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(repository.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create rejects invalid code', async () => {
|
||||||
|
await expect(
|
||||||
|
service.create({
|
||||||
|
name: 'Finance',
|
||||||
|
code: 'FIN 01',
|
||||||
|
userId: 'user-1',
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(repository.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update rejects status field', async () => {
|
||||||
|
await expect(
|
||||||
|
service.update('div-1', {
|
||||||
|
status: 'active',
|
||||||
|
userId: 'user-1',
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateStatus updates via repository', async () => {
|
||||||
|
repository.updateStatus.mockResolvedValue(sample);
|
||||||
|
await service.updateStatus('div-1', 'active', 'user-1');
|
||||||
|
expect(repository.updateStatus).toHaveBeenCalledWith(
|
||||||
|
'div-1',
|
||||||
|
expect.objectContaining({ value: 'active' }),
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('importCsv creates rows and fails batch on invalid status', async () => {
|
||||||
|
await expect(
|
||||||
|
service.importCsv('name,code,status\nFinance,FIN,nope', 'user-1'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(repository.createMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('importCsv fails batch on invalid name or code', async () => {
|
||||||
|
await expect(
|
||||||
|
service.importCsv('name,code\nFinance1,FIN', 'user-1'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
await expect(
|
||||||
|
service.importCsv('name,code\nFinance,FIN 01', 'user-1'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(repository.createMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('importCsv imports valid rows', async () => {
|
||||||
|
repository.createMany.mockResolvedValue(1);
|
||||||
|
const result = await service.importCsv(
|
||||||
|
'name,code,status\nFinance,FIN,draft',
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
expect(result.imported).toBe(1);
|
||||||
|
expect(repository.createMany).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findById returns mapped item', async () => {
|
||||||
|
repository.findById.mockResolvedValue(sample);
|
||||||
|
const result = await service.findById('div-1');
|
||||||
|
expect(result.id).toBe('div-1');
|
||||||
|
expect(result.status).toBe('draft');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update trims and validates name and code', async () => {
|
||||||
|
repository.update.mockResolvedValue({
|
||||||
|
...sample,
|
||||||
|
name: 'Operations',
|
||||||
|
code: 'OPS',
|
||||||
|
});
|
||||||
|
await service.update('div-1', {
|
||||||
|
name: 'Operations',
|
||||||
|
code: 'OPS',
|
||||||
|
userId: 'user-1',
|
||||||
|
});
|
||||||
|
expect(repository.update).toHaveBeenCalledWith(
|
||||||
|
'div-1',
|
||||||
|
expect.objectContaining({ name: 'Operations', code: 'OPS' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update rejects invalid name or code', async () => {
|
||||||
|
await expect(
|
||||||
|
service.update('div-1', { name: 'Ops1', userId: 'user-1' }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
await expect(
|
||||||
|
service.update('div-1', { code: 'OPS 1', userId: 'user-1' }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delete, bulkDelete, and bulkUpdateStatus delegate', async () => {
|
||||||
|
repository.delete.mockResolvedValue(undefined);
|
||||||
|
repository.bulkDelete.mockResolvedValue(2);
|
||||||
|
repository.bulkUpdateStatus.mockResolvedValue(2);
|
||||||
|
await service.delete('div-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 rejects empty, oversized, and headerless files', async () => {
|
||||||
|
await expect(service.importCsv('', 'user-1')).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
service.importCsv('code\nFIN', 'user-1'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
const huge = [
|
||||||
|
'name,code',
|
||||||
|
...Array.from({ length: 501 }, () => 'A,A'),
|
||||||
|
].join('\n');
|
||||||
|
await expect(service.importCsv(huge, 'user-1')).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('importCsv rejects missing name or code cells', async () => {
|
||||||
|
await expect(
|
||||||
|
service.importCsv('name,code\n,FIN', 'user-1'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('importCsv rejects invalid status without echoing it', async () => {
|
||||||
|
await expect(
|
||||||
|
service.importCsv('name,code,status\nFinance,FIN,nope', 'user-1'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(repository.createMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update with only userId still calls repository', async () => {
|
||||||
|
repository.update.mockResolvedValue(sample);
|
||||||
|
await service.update('div-1', { userId: 'user-1' });
|
||||||
|
expect(repository.update).toHaveBeenCalledWith(
|
||||||
|
'div-1',
|
||||||
|
expect.objectContaining({ userId: 'user-1' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { PaginationResponse } from '../../../common/http/response';
|
||||||
|
import { toListPage } from '../../../common/http/response';
|
||||||
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
|
import type {
|
||||||
|
CreateDivisionInput,
|
||||||
|
Division,
|
||||||
|
UpdateDivisionInput,
|
||||||
|
} from './division';
|
||||||
|
import { isValidDivisionCode, isValidDivisionName } from './division-fields';
|
||||||
|
import { DivisionsRepository } from './divisions.repository';
|
||||||
|
|
||||||
|
export type ListDivisionsQuery = {
|
||||||
|
readonly name?: string;
|
||||||
|
readonly code?: string;
|
||||||
|
readonly status?: string;
|
||||||
|
readonly search?: string;
|
||||||
|
readonly page?: number;
|
||||||
|
readonly limit?: number;
|
||||||
|
readonly offset?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const VISIBLE_FIELDS = [
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'code',
|
||||||
|
'status',
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
'createdBy',
|
||||||
|
'updatedBy',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DivisionsService {
|
||||||
|
constructor(private readonly divisionsRepository: DivisionsRepository) {}
|
||||||
|
|
||||||
|
async list(
|
||||||
|
query: ListDivisionsQuery,
|
||||||
|
): Promise<PaginationResponse<ReturnType<DivisionsService['toListItem']>>> {
|
||||||
|
const page = toListPage(query);
|
||||||
|
const { data, total } = await this.divisionsRepository.list({
|
||||||
|
name: query.name,
|
||||||
|
code: query.code,
|
||||||
|
status: query.status,
|
||||||
|
search: query.search,
|
||||||
|
limit: page.limit,
|
||||||
|
offset: page.offset,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
data: data.map((item) => this.toListItem(item)),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(
|
||||||
|
id: string,
|
||||||
|
): Promise<ReturnType<DivisionsService['toListItem']>> {
|
||||||
|
const division = await this.divisionsRepository.findById(id);
|
||||||
|
if (!division) {
|
||||||
|
throw new NotFoundException('Division not found');
|
||||||
|
}
|
||||||
|
return this.toListItem(division);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
status?: string;
|
||||||
|
userId: string;
|
||||||
|
}): Promise<ReturnType<DivisionsService['toListItem']>> {
|
||||||
|
const name = this.assertName(input.name);
|
||||||
|
const code = this.assertCode(input.code);
|
||||||
|
const created = await this.divisionsRepository.create({
|
||||||
|
name,
|
||||||
|
code,
|
||||||
|
status: input.status
|
||||||
|
? Status.create(input.status)
|
||||||
|
: Status.create(Status.DEFAULT),
|
||||||
|
userId: input.userId,
|
||||||
|
});
|
||||||
|
return this.toListItem(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
input: {
|
||||||
|
name?: string;
|
||||||
|
code?: string;
|
||||||
|
status?: unknown;
|
||||||
|
userId: string;
|
||||||
|
},
|
||||||
|
): Promise<ReturnType<DivisionsService['toListItem']>> {
|
||||||
|
if (input.status !== undefined) {
|
||||||
|
throw new BadRequestException('status cannot be updated via PATCH');
|
||||||
|
}
|
||||||
|
const payload: UpdateDivisionInput = {
|
||||||
|
name: input.name !== undefined ? this.assertName(input.name) : undefined,
|
||||||
|
code: input.code !== undefined ? this.assertCode(input.code) : undefined,
|
||||||
|
userId: input.userId,
|
||||||
|
};
|
||||||
|
const updated = await this.divisionsRepository.update(id, payload);
|
||||||
|
return this.toListItem(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(
|
||||||
|
id: string,
|
||||||
|
statusRaw: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<ReturnType<DivisionsService['toListItem']>> {
|
||||||
|
const status = Status.create(statusRaw);
|
||||||
|
const updated = await this.divisionsRepository.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.divisionsRepository.bulkUpdateStatus(
|
||||||
|
ids,
|
||||||
|
status,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
return { updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await this.divisionsRepository.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async bulkDelete(ids: string[]): Promise<{ deleted: number }> {
|
||||||
|
const deleted = await this.divisionsRepository.bulkDelete(ids);
|
||||||
|
return { deleted };
|
||||||
|
}
|
||||||
|
|
||||||
|
async importCsv(csv: string, userId: string): Promise<{ imported: number }> {
|
||||||
|
const lines = csv
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0);
|
||||||
|
if (lines.length === 0) {
|
||||||
|
throw new BadRequestException('CSV is empty');
|
||||||
|
}
|
||||||
|
if (lines.length > 501) {
|
||||||
|
throw new BadRequestException('CSV exceeds maximum of 500 data rows');
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = lines[0].split(',').map((h) => h.trim().toLowerCase());
|
||||||
|
const nameIdx = header.indexOf('name');
|
||||||
|
const codeIdx = header.indexOf('code');
|
||||||
|
const statusIdx = header.indexOf('status');
|
||||||
|
if (nameIdx < 0 || codeIdx < 0) {
|
||||||
|
throw new BadRequestException('CSV must include name and code headers');
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
const rows: { name: string; code: string; status?: string }[] = [];
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const cols = lines[i].split(',').map((c) => c.trim());
|
||||||
|
const name = cols[nameIdx] ?? '';
|
||||||
|
const code = cols[codeIdx] ?? '';
|
||||||
|
const status = statusIdx >= 0 ? cols[statusIdx] : undefined;
|
||||||
|
if (!name || !code) {
|
||||||
|
errors.push(`row ${i + 1}: name and code are required`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isValidDivisionName(name)) {
|
||||||
|
errors.push(`row ${i + 1}: invalid name`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isValidDivisionCode(code)) {
|
||||||
|
errors.push(`row ${i + 1}: invalid code`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
try {
|
||||||
|
Status.create(status);
|
||||||
|
} catch {
|
||||||
|
errors.push(`row ${i + 1}: invalid status`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.push({ name, code, status: status || undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
message: 'CSV validation failed',
|
||||||
|
errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputs: CreateDivisionInput[] = rows.map((row) => ({
|
||||||
|
name: row.name,
|
||||||
|
code: row.code,
|
||||||
|
status: row.status
|
||||||
|
? Status.create(row.status)
|
||||||
|
: Status.create(Status.DEFAULT),
|
||||||
|
userId,
|
||||||
|
}));
|
||||||
|
await this.divisionsRepository.createMany(inputs);
|
||||||
|
return { imported: rows.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
toListItem(division: Division) {
|
||||||
|
return {
|
||||||
|
id: division.id,
|
||||||
|
name: division.name,
|
||||||
|
code: division.code,
|
||||||
|
status: division.status.value,
|
||||||
|
createdAt: division.createdAt.value,
|
||||||
|
updatedAt: division.updatedAt.value,
|
||||||
|
createdBy: division.createdBy,
|
||||||
|
updatedBy: division.updatedBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get visibleFields(): readonly string[] {
|
||||||
|
return VISIBLE_FIELDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertName(raw: string): string {
|
||||||
|
const name = raw.trim();
|
||||||
|
if (!isValidDivisionName(name)) {
|
||||||
|
throw new BadRequestException('Invalid division name');
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertCode(raw: string): string {
|
||||||
|
const code = raw.trim();
|
||||||
|
if (!isValidDivisionCode(code)) {
|
||||||
|
throw new BadRequestException('Invalid division code');
|
||||||
|
}
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
ArrayNotEmpty,
|
||||||
|
IsArray,
|
||||||
|
IsIn,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { PaginationQueryDto } from '../../../../common/http/response';
|
||||||
|
import { CORE_STATUSES } from '../../../../common/value-objects/status/status';
|
||||||
|
import {
|
||||||
|
DIVISION_CODE_MAX_LENGTH,
|
||||||
|
DIVISION_CODE_PATTERN,
|
||||||
|
DIVISION_NAME_MAX_LENGTH,
|
||||||
|
DIVISION_NAME_PATTERN,
|
||||||
|
} from '../division-fields';
|
||||||
|
|
||||||
|
export class CreateDivisionDto {
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'Human Resources',
|
||||||
|
maxLength: DIVISION_NAME_MAX_LENGTH,
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(DIVISION_NAME_MAX_LENGTH)
|
||||||
|
@Matches(DIVISION_NAME_PATTERN, {
|
||||||
|
message: 'name must contain only letters and spaces',
|
||||||
|
})
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'HR', maxLength: DIVISION_CODE_MAX_LENGTH })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(DIVISION_CODE_MAX_LENGTH)
|
||||||
|
@Matches(DIVISION_CODE_PATTERN, {
|
||||||
|
message: 'code must contain only letters, numbers, and underscores',
|
||||||
|
})
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: CORE_STATUSES })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([...CORE_STATUSES])
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateDivisionDto {
|
||||||
|
@ApiPropertyOptional({ example: 'Human Resources' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(DIVISION_NAME_MAX_LENGTH)
|
||||||
|
@Matches(DIVISION_NAME_PATTERN, {
|
||||||
|
message: 'name must contain only letters and spaces',
|
||||||
|
})
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'HR' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(DIVISION_CODE_MAX_LENGTH)
|
||||||
|
@Matches(DIVISION_CODE_PATTERN, {
|
||||||
|
message: 'code must contain only letters, numbers, and underscores',
|
||||||
|
})
|
||||||
|
code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateDivisionStatusDto {
|
||||||
|
@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 ListDivisionsQueryDto extends PaginationQueryDto {
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
code?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: CORE_STATUSES })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([...CORE_STATUSES])
|
||||||
|
status?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Case-insensitive match on name or code',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DivisionDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
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('Divisions (e2e)', () => {
|
||||||
|
let app: INestApplication<App>;
|
||||||
|
let db: DrizzleDB;
|
||||||
|
|
||||||
|
const password = 'password123';
|
||||||
|
const adminUsername = `div_admin_${Date.now()}`;
|
||||||
|
const otherUsername = `div_other_${Date.now()}`;
|
||||||
|
|
||||||
|
let adminAccessToken: string;
|
||||||
|
let adminUserId: string;
|
||||||
|
let otherAccessToken: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleFixture.createNestApplication();
|
||||||
|
configureApp(app, {
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
SWAGGER_ENABLED: 'false',
|
||||||
|
});
|
||||||
|
await app.init();
|
||||||
|
db = app.get(DRIZZLE);
|
||||||
|
|
||||||
|
const adminReg = await request(app.getHttpServer())
|
||||||
|
.post('/auth/register')
|
||||||
|
.send({ username: adminUsername, password })
|
||||||
|
.expect(201);
|
||||||
|
adminAccessToken = (adminReg.body as { accessToken: string }).accessToken;
|
||||||
|
|
||||||
|
const adminMe = await request(app.getHttpServer())
|
||||||
|
.get('/auth/me')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
adminUserId = (adminMe.body as { id: string }).id;
|
||||||
|
|
||||||
|
const otherReg = await request(app.getHttpServer())
|
||||||
|
.post('/auth/register')
|
||||||
|
.send({ username: otherUsername, password })
|
||||||
|
.expect(201);
|
||||||
|
otherAccessToken = (otherReg.body as { accessToken: string }).accessToken;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const [priv] = await db
|
||||||
|
.insert(privileges)
|
||||||
|
.values({
|
||||||
|
name: 'Division Admin',
|
||||||
|
code: `DIV_ADMIN_${now}`,
|
||||||
|
status: 'active',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
createdBy: adminUserId,
|
||||||
|
updatedBy: adminUserId,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const keys = await db.select().from(privilegeKeys);
|
||||||
|
const detailRows = keys.flatMap((key) =>
|
||||||
|
PRIVILEGE_ACTIONS.map((action) => ({
|
||||||
|
privilegeId: priv.id,
|
||||||
|
privilegeKeyId: key.id,
|
||||||
|
action,
|
||||||
|
value: true,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
await db.insert(privilegeDetails).values(detailRows);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({ privilegeId: priv.id, updatedAt: Date.now() })
|
||||||
|
.where(eq(users.id, adminUserId));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forbids divisions list without permission', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get('/divisions')
|
||||||
|
.set('Authorization', `Bearer ${otherAccessToken}`)
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unauthenticated access', async () => {
|
||||||
|
await request(app.getHttpServer()).get('/divisions').expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CRUD divisions with name/code rules, status, search, and bulk', async () => {
|
||||||
|
const created = await request(app.getHttpServer())
|
||||||
|
.post('/divisions')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.send({ name: 'Human Resources', code: 'HR' })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(created.body).toMatchObject({
|
||||||
|
name: 'Human Resources',
|
||||||
|
code: 'HR',
|
||||||
|
status: 'draft',
|
||||||
|
createdBy: adminUserId,
|
||||||
|
});
|
||||||
|
const id = created.body.id as string;
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/divisions')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.send({ name: 'Finance1', code: 'FIN' })
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/divisions')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.send({ name: 'Finance', code: 'FIN 01' })
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get(`/divisions/${id}`)
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.patch(`/divisions/${id}`)
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.send({ name: 'People Operations' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.patch(`/divisions/${id}`)
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.send({ status: 'active' })
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.patch(`/divisions/${id}/status`)
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.send({ status: 'active' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const list = await request(app.getHttpServer())
|
||||||
|
.get('/divisions?search=People')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(list.body.data.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(list.body.meta).toBeDefined();
|
||||||
|
|
||||||
|
const extra = await request(app.getHttpServer())
|
||||||
|
.post('/divisions')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.send({ name: 'Operations', code: 'OPS' })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/divisions/bulk-status')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.send({ ids: [extra.body.id], status: 'archived' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/divisions/bulk-delete')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.send({ ids: [extra.body.id] })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.delete(`/divisions/${id}`)
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.expect(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('imports divisions from CSV', async () => {
|
||||||
|
const csv = `name,code,status\nImported Division,IMP_${Date.now().toString().slice(-8)},draft\n`;
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post('/divisions/import')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.attach('file', Buffer.from(csv, 'utf8'), 'divisions.csv')
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(res.body).toMatchObject({ imported: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -45,9 +45,7 @@ describe('Privileges (e2e)', () => {
|
|||||||
.post('/auth/register')
|
.post('/auth/register')
|
||||||
.send({ username: adminUsername, password })
|
.send({ username: adminUsername, password })
|
||||||
.expect(201);
|
.expect(201);
|
||||||
adminAccessToken = (
|
adminAccessToken = (adminReg.body as { accessToken: string }).accessToken;
|
||||||
adminReg.body as { accessToken: string }
|
|
||||||
).accessToken;
|
|
||||||
|
|
||||||
const adminMe = await request(app.getHttpServer())
|
const adminMe = await request(app.getHttpServer())
|
||||||
.get('/auth/me')
|
.get('/auth/me')
|
||||||
@@ -63,9 +61,7 @@ describe('Privileges (e2e)', () => {
|
|||||||
.post('/auth/register')
|
.post('/auth/register')
|
||||||
.send({ username: otherUsername, password })
|
.send({ username: otherUsername, password })
|
||||||
.expect(201);
|
.expect(201);
|
||||||
otherAccessToken = (
|
otherAccessToken = (otherReg.body as { accessToken: string }).accessToken;
|
||||||
otherReg.body as { accessToken: string }
|
|
||||||
).accessToken;
|
|
||||||
const otherMe = await request(app.getHttpServer())
|
const otherMe = await request(app.getHttpServer())
|
||||||
.get('/auth/me')
|
.get('/auth/me')
|
||||||
.set('Authorization', `Bearer ${otherAccessToken}`)
|
.set('Authorization', `Bearer ${otherAccessToken}`)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"target": "ES2023",
|
"target": "ES2023",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
|
"rootDir": ".",
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"types": ["node", "jest"],
|
"types": ["node", "jest"],
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user