diff --git a/drizzle/migrations/0007_employees.sql b/drizzle/migrations/0007_employees.sql new file mode 100644 index 0000000..ede22f7 --- /dev/null +++ b/drizzle/migrations/0007_employees.sql @@ -0,0 +1,19 @@ +CREATE TABLE "employees" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "code" varchar(16) NOT NULL, + "name" varchar(64) NOT NULL, + "phone" text NOT NULL, + "position" text 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 "employees" ADD CONSTRAINT "employees_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 "employees" ADD CONSTRAINT "employees_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 "employees_code_unique" ON "employees" USING btree ("code"); +--> statement-breakpoint +INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES + ('CONFIGURATION.EMPLOYEE', 'Employees', 6); diff --git a/drizzle/migrations/meta/0007_snapshot.json b/drizzle/migrations/meta/0007_snapshot.json new file mode 100644 index 0000000..447a07d --- /dev/null +++ b/drizzle/migrations/meta/0007_snapshot.json @@ -0,0 +1,1168 @@ +{ + "id": "d8f1a549-f016-6724-c067-1b8c2fh69287", + "prevId": "c7e0f438-ef05-5613-b956-0a7b1eg58176", + "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 + }, + "public.customers": { + "name": "customers", + "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 + }, + "nfc_id": { + "name": "nfc_id", + "type": "text", + "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": { + "customers_code_unique": { + "name": "customers_code_unique", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "customers_nfc_id_unique": { + "name": "customers_nfc_id_unique", + "columns": [ + { + "expression": "nfc_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customers_created_by_users_id_fk": { + "name": "customers_created_by_users_id_fk", + "tableFrom": "customers", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "customers_updated_by_users_id_fk": { + "name": "customers_updated_by_users_id_fk", + "tableFrom": "customers", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_contacts": { + "name": "customer_contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "customer_id": { + "name": "customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "job_title": { + "name": "job_title", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mobile_phone": { + "name": "mobile_phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "customer_contacts_customer_id_idx": { + "name": "customer_contacts_customer_id_idx", + "columns": [ + { + "expression": "customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customer_contacts_customer_id_customers_id_fk": { + "name": "customer_contacts_customer_id_customers_id_fk", + "tableFrom": "customer_contacts", + "tableTo": "customers", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.employees": { + "name": "employees", + "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 + }, + "position": { + "name": "position", + "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": { + "employees_code_unique": { + "name": "employees_code_unique", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "employees_created_by_users_id_fk": { + "name": "employees_created_by_users_id_fk", + "tableFrom": "employees", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "employees_updated_by_users_id_fk": { + "name": "employees_updated_by_users_id_fk", + "tableFrom": "employees", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index 83b3b43..030281d 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1787554000000, "tag": "0006_customers", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1787555000000, + "tag": "0007_employees", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/database/employees-table.ts b/src/database/employees-table.ts new file mode 100644 index 0000000..3f7daa8 --- /dev/null +++ b/src/database/employees-table.ts @@ -0,0 +1,23 @@ +import { pgTable, text, uniqueIndex, uuid, varchar } from 'drizzle-orm/pg-core'; +import { primaryEntityColumns } from './primary-entity-columns'; +import { users } from './schema'; + +/** + * Employees (primary aggregate). + * Kept in a separate module so Drizzle's table type stays resolvable. + */ +export const employees = pgTable( + 'employees', + { + id: uuid('id').defaultRandom().notNull().primaryKey(), + code: varchar('code', { length: 16 }).notNull(), + name: varchar('name', { length: 64 }).notNull(), + phone: text('phone').notNull(), + position: text('position').notNull(), + ...primaryEntityColumns(users), + }, + (t) => [uniqueIndex('employees_code_unique').on(t.code)], +); + +export type EmployeeRow = typeof employees.$inferSelect; +export type NewEmployeeRow = typeof employees.$inferInsert; diff --git a/src/database/schema.ts b/src/database/schema.ts index f85680c..1a17e81 100644 --- a/src/database/schema.ts +++ b/src/database/schema.ts @@ -151,3 +151,8 @@ export { type NewCustomerContactRow, type NewCustomerRow, } from './customers-table'; +export { + employees, + type EmployeeRow, + type NewEmployeeRow, +} from './employees-table'; diff --git a/src/modules/configuration/configuration.module.ts b/src/modules/configuration/configuration.module.ts index 4238763..d614209 100644 --- a/src/modules/configuration/configuration.module.ts +++ b/src/modules/configuration/configuration.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { BranchesModule } from './branches/branches.module'; import { CustomersModule } from './customers/customers.module'; import { DivisionsModule } from './divisions/divisions.module'; +import { EmployeesModule } from './employees/employees.module'; @Module({ - imports: [DivisionsModule, BranchesModule, CustomersModule], - exports: [DivisionsModule, BranchesModule, CustomersModule], + imports: [DivisionsModule, BranchesModule, CustomersModule, EmployeesModule], + exports: [DivisionsModule, BranchesModule, CustomersModule, EmployeesModule], }) export class ConfigurationModule {} diff --git a/src/modules/configuration/employees/dto/employee.dto.ts b/src/modules/configuration/employees/dto/employee.dto.ts new file mode 100644 index 0000000..341c201 --- /dev/null +++ b/src/modules/configuration/employees/dto/employee.dto.ts @@ -0,0 +1,183 @@ +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 { + EMPLOYEE_CODE_MAX_LENGTH, + EMPLOYEE_CODE_PATTERN, + EMPLOYEE_NAME_MAX_LENGTH, + EMPLOYEE_NAME_PATTERN, + EMPLOYEE_POSITIONS, +} from '../employee-fields'; + +export class CreateEmployeeDto { + @ApiProperty({ example: 'EMP_01', maxLength: EMPLOYEE_CODE_MAX_LENGTH }) + @IsString() + @IsNotEmpty() + @MaxLength(EMPLOYEE_CODE_MAX_LENGTH) + @Matches(EMPLOYEE_CODE_PATTERN, { + message: 'code must contain only letters, numbers, and underscores', + }) + code!: string; + + @ApiProperty({ + example: 'Ada Lovelace', + maxLength: EMPLOYEE_NAME_MAX_LENGTH, + }) + @IsString() + @IsNotEmpty() + @MaxLength(EMPLOYEE_NAME_MAX_LENGTH) + @Matches(EMPLOYEE_NAME_PATTERN, { + message: 'name must contain only letters and spaces', + }) + name!: string; + + @ApiProperty({ example: '+6281234567890' }) + @IsString() + @IsNotEmpty() + phone!: string; + + @ApiProperty({ enum: EMPLOYEE_POSITIONS, example: 'sales' }) + @IsIn([...EMPLOYEE_POSITIONS]) + position!: string; + + @ApiPropertyOptional({ enum: CORE_STATUSES }) + @IsOptional() + @IsIn([...CORE_STATUSES]) + status?: string; +} + +export class UpdateEmployeeDto { + @ApiPropertyOptional({ example: 'EMP_01' }) + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(EMPLOYEE_CODE_MAX_LENGTH) + @Matches(EMPLOYEE_CODE_PATTERN, { + message: 'code must contain only letters, numbers, and underscores', + }) + code?: string; + + @ApiPropertyOptional({ example: 'Ada Lovelace' }) + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(EMPLOYEE_NAME_MAX_LENGTH) + @Matches(EMPLOYEE_NAME_PATTERN, { + message: 'name must contain only letters and spaces', + }) + name?: string; + + @ApiPropertyOptional({ example: '+6281234567890' }) + @IsOptional() + @IsString() + @IsNotEmpty() + phone?: string; + + @ApiPropertyOptional({ enum: EMPLOYEE_POSITIONS }) + @IsOptional() + @IsIn([...EMPLOYEE_POSITIONS]) + position?: string; +} + +export class UpdateEmployeeStatusDto { + @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 ListEmployeesQueryDto extends PaginationQueryDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + code?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + name?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + phone?: string; + + @ApiPropertyOptional({ enum: EMPLOYEE_POSITIONS }) + @IsOptional() + @IsIn([...EMPLOYEE_POSITIONS]) + position?: string; + + @ApiPropertyOptional({ enum: CORE_STATUSES }) + @IsOptional() + @IsIn([...CORE_STATUSES]) + status?: string; + + @ApiPropertyOptional({ + description: 'Case-insensitive match on code or name', + }) + @IsOptional() + @IsString() + search?: string; +} + +export class EmployeeDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty() + code!: string; + + @ApiProperty() + name!: string; + + @ApiProperty({ example: '+6281234567890' }) + phone!: string; + + @ApiProperty({ enum: EMPLOYEE_POSITIONS }) + position!: 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; +} diff --git a/src/modules/configuration/employees/employee-fields.spec.ts b/src/modules/configuration/employees/employee-fields.spec.ts new file mode 100644 index 0000000..1974a6f --- /dev/null +++ b/src/modules/configuration/employees/employee-fields.spec.ts @@ -0,0 +1,108 @@ +import { + EMPLOYEE_CODE_MAX_LENGTH, + EMPLOYEE_NAME_MAX_LENGTH, + isAllowedCsvUpload, + isValidEmployeeCode, + isValidEmployeeName, + isValidEmployeePosition, + parseCsvRecord, +} from './employee-fields'; + +describe('employee fields', () => { + describe('isValidEmployeeName', () => { + it.each(['Ada', 'Jean Luc', 'A', 'North West Crew'])( + 'accepts %s', + (name) => { + expect(isValidEmployeeName(name)).toBe(true); + }, + ); + + it.each(['', 'Ada1', 'Jean-Luc', 'EMP_01', ' Ada', 'Ada ', 'Jean Luc'])( + 'rejects %s', + (name) => { + expect(isValidEmployeeName(name)).toBe(false); + }, + ); + + it('rejects names longer than 64 characters', () => { + expect( + isValidEmployeeName('A'.repeat(EMPLOYEE_NAME_MAX_LENGTH + 1)), + ).toBe(false); + expect(isValidEmployeeName('A'.repeat(EMPLOYEE_NAME_MAX_LENGTH))).toBe( + true, + ); + }); + }); + + describe('isValidEmployeeCode', () => { + it.each(['EMP', 'EMP_01', 'A', 'ops2', 'A_b_1'])('accepts %s', (code) => { + expect(isValidEmployeeCode(code)).toBe(true); + }); + + it.each(['', 'EMP 01', 'EMP-01', 'EMP.01', ' EMP', 'EMP '])( + 'rejects %s', + (code) => { + expect(isValidEmployeeCode(code)).toBe(false); + }, + ); + + it('rejects codes longer than 16 characters', () => { + expect( + isValidEmployeeCode('A'.repeat(EMPLOYEE_CODE_MAX_LENGTH + 1)), + ).toBe(false); + expect(isValidEmployeeCode('A'.repeat(EMPLOYEE_CODE_MAX_LENGTH))).toBe( + true, + ); + }); + }); + + describe('isValidEmployeePosition', () => { + it.each(['sales', 'driver', 'crew'])('accepts %s', (position) => { + expect(isValidEmployeePosition(position)).toBe(true); + }); + + it.each(['', 'Sales', 'pilot', 'crew '])('rejects %s', (position) => { + expect(isValidEmployeePosition(position)).toBe(false); + }); + }); + + describe('parseCsvRecord', () => { + it('keeps commas inside quoted fields', () => { + expect(parseCsvRecord('EMP_01,Ada Lovelace,"sales, lead"')).toEqual([ + 'EMP_01', + 'Ada Lovelace', + 'sales, lead', + ]); + }); + + 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: 'employees.csv', + }), + ).toBe(true); + }); + + it('rejects non-csv files', () => { + expect( + isAllowedCsvUpload({ + mimetype: 'application/pdf', + originalname: 'x.pdf', + }), + ).toBe(false); + }); + }); +}); diff --git a/src/modules/configuration/employees/employee-fields.ts b/src/modules/configuration/employees/employee-fields.ts new file mode 100644 index 0000000..d8d0792 --- /dev/null +++ b/src/modules/configuration/employees/employee-fields.ts @@ -0,0 +1,75 @@ +export const EMPLOYEE_NAME_MAX_LENGTH = 64; +export const EMPLOYEE_CODE_MAX_LENGTH = 16; + +/** Letters with single spaces between words. */ +export const EMPLOYEE_NAME_PATTERN = /^[A-Za-z]+(?: [A-Za-z]+)*$/; + +/** Alphanumeric and underscore; no spaces. */ +export const EMPLOYEE_CODE_PATTERN = /^[A-Za-z0-9_]+$/; + +export const EMPLOYEE_POSITIONS = ['sales', 'driver', 'crew'] as const; + +export type EmployeePosition = (typeof EMPLOYEE_POSITIONS)[number]; + +export function isValidEmployeeName(raw: string): boolean { + return ( + typeof raw === 'string' && + raw.length > 0 && + raw.length <= EMPLOYEE_NAME_MAX_LENGTH && + EMPLOYEE_NAME_PATTERN.test(raw) + ); +} + +export function isValidEmployeeCode(raw: string): boolean { + return ( + typeof raw === 'string' && + raw.length > 0 && + raw.length <= EMPLOYEE_CODE_MAX_LENGTH && + EMPLOYEE_CODE_PATTERN.test(raw) + ); +} + +export function isValidEmployeePosition(raw: string): raw is EmployeePosition { + return (EMPLOYEE_POSITIONS as readonly string[]).includes(raw); +} + +/** RFC 4180-style record split that preserves commas inside quotes. */ +export function parseCsvRecord(line: string): string[] { + const cells: string[] = []; + let current = ''; + let inQuotes = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (inQuotes) { + if (ch === '"') { + if (line[i + 1] === '"') { + current += '"'; + i += 1; + } else { + inQuotes = false; + } + } else { + current += ch; + } + } else if (ch === '"') { + inQuotes = true; + } else if (ch === ',') { + cells.push(current.trim()); + current = ''; + } else { + current += ch; + } + } + cells.push(current.trim()); + return cells; +} + +export function isAllowedCsvUpload(file: { + mimetype: string; + originalname: string; +}): boolean { + return ( + file.mimetype.includes('csv') || + file.originalname.toLowerCase().endsWith('.csv') + ); +} diff --git a/src/modules/configuration/employees/employee.ts b/src/modules/configuration/employees/employee.ts new file mode 100644 index 0000000..0091583 --- /dev/null +++ b/src/modules/configuration/employees/employee.ts @@ -0,0 +1,45 @@ +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 { EmployeePosition } from './employee-fields'; + +export type Employee = { + readonly id: string; + readonly code: string; + readonly name: string; + readonly phone: PhoneNumber; + readonly position: EmployeePosition; + readonly status: Status; + readonly createdAt: DateTime; + readonly updatedAt: DateTime; + readonly createdBy: string; + readonly updatedBy: string; +}; + +export type CreateEmployeeInput = { + readonly code: string; + readonly name: string; + readonly phone: PhoneNumber; + readonly position: EmployeePosition; + readonly status?: Status; + readonly userId: string; +}; + +export type UpdateEmployeeInput = { + readonly code?: string; + readonly name?: string; + readonly phone?: PhoneNumber; + readonly position?: EmployeePosition; + readonly userId: string; +}; + +export type ListEmployeesFilters = { + readonly code?: string; + readonly name?: string; + readonly phone?: string; + readonly position?: string; + readonly status?: string; + readonly search?: string; + readonly limit: number; + readonly offset: number; +}; diff --git a/src/modules/configuration/employees/employees-read.controller.spec.ts b/src/modules/configuration/employees/employees-read.controller.spec.ts new file mode 100644 index 0000000..537957a --- /dev/null +++ b/src/modules/configuration/employees/employees-read.controller.spec.ts @@ -0,0 +1,36 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EmployeesReadController } from './employees-read.controller'; +import { EmployeesService } from './employees.service'; + +describe('EmployeesReadController', () => { + let controller: EmployeesReadController; + const service = { + list: jest.fn(), + findById: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + const moduleRef: TestingModule = await Test.createTestingModule({ + controllers: [EmployeesReadController], + providers: [{ provide: EmployeesService, useValue: service }], + }).compile(); + controller = moduleRef.get(EmployeesReadController); + }); + + 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: 'emp-1' }); + await expect(controller.findOne('emp-1')).resolves.toEqual({ + id: 'emp-1', + }); + }); +}); diff --git a/src/modules/configuration/employees/employees-read.controller.ts b/src/modules/configuration/employees/employees-read.controller.ts new file mode 100644 index 0000000..9d59026 --- /dev/null +++ b/src/modules/configuration/employees/employees-read.controller.ts @@ -0,0 +1,64 @@ +import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiForbiddenResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; +import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator'; +import { + Pagination, + type PaginationResponse, + PaginationMetaDto, +} from '../../../common/http/response'; +import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger'; +import { EmployeeDto, ListEmployeesQueryDto } from './dto/employee.dto'; +import { EmployeesService } from './employees.service'; + +export const EMPLOYEE_PRIVILEGE_KEY = 'CONFIGURATION.EMPLOYEE'; + +@ApiTags('employees') +@ApiBearerAuth(BEARER_AUTH_NAME) +@Controller('employees') +export class EmployeesReadController { + constructor(private readonly employeesService: EmployeesService) {} + + @Get() + @Pagination() + @RequirePrivilege(EMPLOYEE_PRIVILEGE_KEY, 'view') + @ApiOperation({ summary: 'List employees' }) + @ApiOkResponse({ + schema: { + properties: { + data: { + type: 'array', + items: { $ref: '#/components/schemas/EmployeeDto' }, + }, + meta: { $ref: '#/components/schemas/PaginationMetaDto' }, + }, + }, + }) + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + list( + @Query() query: ListEmployeesQueryDto, + ): Promise> { + return this.employeesService.list(query); + } + + @Get(':id') + @RequirePrivilege(EMPLOYEE_PRIVILEGE_KEY, 'view') + @ApiOperation({ summary: 'Get employee detail' }) + @ApiOkResponse({ type: EmployeeDto }) + @ApiNotFoundResponse() + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + findOne(@Param('id', ParseUUIDPipe) id: string): Promise { + return this.employeesService.findById(id); + } +} + +void PaginationMetaDto; diff --git a/src/modules/configuration/employees/employees-write.controller.spec.ts b/src/modules/configuration/employees/employees-write.controller.spec.ts new file mode 100644 index 0000000..658360b --- /dev/null +++ b/src/modules/configuration/employees/employees-write.controller.spec.ts @@ -0,0 +1,83 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EmployeesWriteController } from './employees-write.controller'; +import { EmployeesService } from './employees.service'; + +const createDto = { + code: 'EMP_01', + name: 'Ada Lovelace', + phone: '+6281234567890', + position: 'sales', +}; + +describe('EmployeesWriteController', () => { + let controller: EmployeesWriteController; + 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: [EmployeesWriteController], + providers: [{ provide: EmployeesService, useValue: service }], + }).compile(); + controller = moduleRef.get(EmployeesWriteController); + }); + + it('create passes dto fields and user id', async () => { + service.create.mockResolvedValue({ id: 'emp-1' }); + await controller.create(createDto, 'user-1'); + expect(service.create).toHaveBeenCalledWith({ + ...createDto, + status: undefined, + userId: 'user-1', + }); + }); + + it('update, updateStatus, and delete delegate', async () => { + service.update.mockResolvedValue({ id: 'emp-1' }); + service.updateStatus.mockResolvedValue({ id: 'emp-1' }); + service.delete.mockResolvedValue(undefined); + await controller.update('emp-1', { name: 'Ada Lovelace' }, 'user-1'); + await controller.updateStatus('emp-1', { status: 'active' }, 'user-1'); + await controller.delete('emp-1'); + expect(service.updateStatus).toHaveBeenCalledWith( + 'emp-1', + 'active', + 'user-1', + ); + expect(service.delete).toHaveBeenCalledWith('emp-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: ['emp-1'] }); + await controller.bulkStatus( + { ids: ['emp-1'], status: 'archived' }, + 'user-1', + ); + await controller.importCsv( + { + buffer: Buffer.from( + 'code,name,phone,position\nEMP_01,Ada,+6281234567890,sales', + ), + }, + 'user-1', + ); + expect(service.importCsv).toHaveBeenCalled(); + }); + + it('importCsv uses empty string when file is missing', async () => { + service.importCsv.mockResolvedValue({ imported: 0 }); + await controller.importCsv(undefined, 'user-1'); + expect(service.importCsv).toHaveBeenCalledWith('', 'user-1'); + }); +}); diff --git a/src/modules/configuration/employees/employees-write.controller.ts b/src/modules/configuration/employees/employees-write.controller.ts new file mode 100644 index 0000000..c7039d8 --- /dev/null +++ b/src/modules/configuration/employees/employees-write.controller.ts @@ -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 { isAllowedCsvUpload } from './employee-fields'; +import { EMPLOYEE_PRIVILEGE_KEY } from './employees-read.controller'; +import { EmployeesService } from './employees.service'; +import { + BulkIdsDto, + BulkStatusDto, + CreateEmployeeDto, + EmployeeDto, + UpdateEmployeeDto, + UpdateEmployeeStatusDto, +} from './dto/employee.dto'; + +@ApiTags('employees') +@ApiBearerAuth(BEARER_AUTH_NAME) +@Controller('employees') +export class EmployeesWriteController { + constructor(private readonly employeesService: EmployeesService) {} + + @Post('import') + @RequirePrivilege(EMPLOYEE_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 employees 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.employeesService.importCsv(csv, userId); + } + + @Post('bulk-delete') + @HttpCode(200) + @RequirePrivilege(EMPLOYEE_PRIVILEGE_KEY, 'delete') + @ApiOperation({ summary: 'Bulk delete employees' }) + @ApiOkResponse({ + schema: { properties: { deleted: { type: 'number' } } }, + }) + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + bulkDelete(@Body() dto: BulkIdsDto): Promise<{ deleted: number }> { + return this.employeesService.bulkDelete(dto.ids); + } + + @Post('bulk-status') + @HttpCode(200) + @RequirePrivilege(EMPLOYEE_PRIVILEGE_KEY, 'update') + @ApiOperation({ summary: 'Bulk update employee status' }) + @ApiOkResponse({ + schema: { properties: { updated: { type: 'number' } } }, + }) + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + bulkStatus( + @Body() dto: BulkStatusDto, + @CurrentUser('id') userId: string, + ): Promise<{ updated: number }> { + return this.employeesService.bulkUpdateStatus(dto.ids, dto.status, userId); + } + + @Post() + @RequirePrivilege(EMPLOYEE_PRIVILEGE_KEY, 'create') + @ApiOperation({ summary: 'Create employee' }) + @ApiCreatedResponse({ type: EmployeeDto }) + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + create( + @Body() dto: CreateEmployeeDto, + @CurrentUser('id') userId: string, + ): Promise { + return this.employeesService.create({ + code: dto.code, + name: dto.name, + phone: dto.phone, + position: dto.position, + status: dto.status, + userId, + }); + } + + @Patch(':id/status') + @RequirePrivilege(EMPLOYEE_PRIVILEGE_KEY, 'update') + @ApiOperation({ summary: 'Update employee status' }) + @ApiOkResponse({ type: EmployeeDto }) + @ApiNotFoundResponse() + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + updateStatus( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateEmployeeStatusDto, + @CurrentUser('id') userId: string, + ): Promise { + return this.employeesService.updateStatus(id, dto.status, userId); + } + + @Patch(':id') + @RequirePrivilege(EMPLOYEE_PRIVILEGE_KEY, 'update') + @ApiOperation({ summary: 'Update employee (not status)' }) + @ApiOkResponse({ type: EmployeeDto }) + @ApiNotFoundResponse() + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateEmployeeDto, + @CurrentUser('id') userId: string, + ): Promise { + return this.employeesService.update(id, { + code: dto.code, + name: dto.name, + phone: dto.phone, + position: dto.position, + userId, + }); + } + + @Delete(':id') + @HttpCode(204) + @RequirePrivilege(EMPLOYEE_PRIVILEGE_KEY, 'delete') + @ApiOperation({ summary: 'Delete employee' }) + @ApiNoContentResponse() + @ApiNotFoundResponse() + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + async delete(@Param('id', ParseUUIDPipe) id: string): Promise { + await this.employeesService.delete(id); + } +} diff --git a/src/modules/configuration/employees/employees.module.ts b/src/modules/configuration/employees/employees.module.ts new file mode 100644 index 0000000..ffee11b --- /dev/null +++ b/src/modules/configuration/employees/employees.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { EmployeesReadController } from './employees-read.controller'; +import { EmployeesWriteController } from './employees-write.controller'; +import { EmployeesRepository } from './employees.repository'; +import { EmployeesService } from './employees.service'; + +@Module({ + controllers: [EmployeesReadController, EmployeesWriteController], + providers: [EmployeesRepository, EmployeesService], + exports: [EmployeesService], +}) +export class EmployeesModule {} diff --git a/src/modules/configuration/employees/employees.repository.spec.ts b/src/modules/configuration/employees/employees.repository.spec.ts new file mode 100644 index 0000000..564d643 --- /dev/null +++ b/src/modules/configuration/employees/employees.repository.spec.ts @@ -0,0 +1,234 @@ +import { 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 { EmployeesRepository } from './employees.repository'; + +describe('EmployeesRepository', () => { + let repository: EmployeesRepository; + + 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: 'emp-1', + code: 'EMP_01', + name: 'Ada Lovelace', + phone: '+6281234567890', + position: 'sales', + status: 'draft', + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + createdBy: 'user-1', + updatedBy: 'user-1', + }; + + const createInput = { + code: 'EMP_01', + name: 'Ada Lovelace', + phone: PhoneNumber.create('+6281234567890'), + position: 'sales' as const, + userId: 'user-1', + }; + + beforeEach(async () => { + jest.clearAllMocks(); + where.mockImplementation(() => ({ limit, orderBy, returning })); + 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]); + transaction.mockImplementation((fn: (tx: typeof db) => unknown) => fn(db)); + + const moduleRef: TestingModule = await Test.createTestingModule({ + providers: [EmployeesRepository, { provide: DRIZZLE, useValue: db }], + }).compile(); + repository = moduleRef.get(EmployeesRepository); + }); + + it('findById maps a row to domain Employee', async () => { + limit.mockResolvedValueOnce([row]); + const employee = await repository.findById('emp-1'); + expect(employee).toMatchObject({ + id: 'emp-1', + code: 'EMP_01', + name: 'Ada Lovelace', + position: 'sales', + createdBy: 'user-1', + }); + expect(employee?.phone.value).toBe('+6281234567890'); + expect(employee?.status.value).toBe('draft'); + expect(employee?.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 employee = await repository.findByCode('EMP_01'); + expect(employee?.code).toBe('EMP_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: 'Ada', + code: 'EMP', + phone: '+628', + position: 'sales', + status: 'draft', + search: 'ada', + limit: 10, + offset: 0, + }); + expect(result.total).toBe(1); + expect(result.data[0].code).toBe('EMP_01'); + expect(result.data[0].phone.value).toBe('+6281234567890'); + }); + + it('create inserts and maps unique violations', async () => { + returning.mockResolvedValueOnce([row]); + const created = await repository.create(createInput); + expect(created.code).toBe('EMP_01'); + + returning.mockRejectedValueOnce({ code: '23505' }); + await expect(repository.create(createInput)).rejects.toBeInstanceOf( + ConflictException, + ); + + returning.mockRejectedValueOnce({ + cause: { code: '23505' }, + }); + await expect(repository.create(createInput)).rejects.toBeInstanceOf( + ConflictException, + ); + }); + + it('create rethrows unknown errors', async () => { + returning.mockRejectedValue(new Error('db down')); + await expect(repository.create(createInput)).rejects.toThrow('db down'); + }); + + it('createMany returns 0 for an empty batch and inserts otherwise', async () => { + await expect(repository.createMany([])).resolves.toBe(0); + transaction.mockImplementation( + async (fn: (tx: typeof db) => Promise) => { + await fn(db); + }, + ); + await expect(repository.createMany([createInput])).resolves.toBe(1); + }); + + it('createMany maps unique violations', async () => { + transaction.mockRejectedValue({ code: '23505' }); + await expect(repository.createMany([createInput])).rejects.toBeInstanceOf( + ConflictException, + ); + }); + + 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('emp-1', { code: 'EMP_02', 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: 'emp-1' }, { id: 'emp-2' }]); + await expect( + repository.bulkUpdateStatus( + ['emp-1', 'emp-2'], + Status.create('active'), + 'user-1', + ), + ).resolves.toBe(2); + returning.mockResolvedValue([{ id: 'emp-1' }]); + await expect(repository.bulkDelete(['emp-1'])).resolves.toBe(1); + }); + + it('extendListQuery is a passthrough hook', () => { + const qb = { join: true }; + expect(repository.extendListQuery(qb, { limit: 10, offset: 0 })).toBe(qb); + }); +}); diff --git a/src/modules/configuration/employees/employees.repository.ts b/src/modules/configuration/employees/employees.repository.ts new file mode 100644 index 0000000..385004f --- /dev/null +++ b/src/modules/configuration/employees/employees.repository.ts @@ -0,0 +1,290 @@ +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 { 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 { employees, type EmployeeRow } from '../../../database/employees-table'; +import type { EmployeePosition } from './employee-fields'; +import type { + CreateEmployeeInput, + Employee, + ListEmployeesFilters, + UpdateEmployeeInput, +} from './employee'; + +@Injectable() +export class EmployeesRepository { + constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {} + + async list( + filters: ListEmployeesFilters, + ): Promise<{ data: Employee[]; total: number }> { + const where = this.buildListWhere(filters); + const totalRows = await this.db + .select({ total: count() }) + .from(employees) + .where(where); + const totalRow = totalRows[0]; + + let qb = this.db.select().from(employees).$dynamic(); + qb = this.extendListQuery(qb, filters); + const rows = await qb + .where(where) + .orderBy(asc(employees.code)) + .limit(filters.limit) + .offset(filters.offset); + + return { + data: rows.map((row) => this.toDomain(row)), + total: Number(totalRow?.total ?? 0), + }; + } + + /** + * Hook for modules to add joins/extra predicates without forking list. + */ + extendListQuery(qb: T, filters: ListEmployeesFilters): T { + void filters; + return qb; + } + + async findById(id: string): Promise { + const rows: EmployeeRow[] = await this.db + .select() + .from(employees) + .where(eq(employees.id, id)) + .limit(1); + const row = rows[0]; + return row ? this.toDomain(row) : null; + } + + async findByCode(code: string): Promise { + const rows = await this.db + .select() + .from(employees) + .where(eq(employees.code, code)) + .limit(1); + const row = rows[0]; + return row ? this.toDomain(row) : null; + } + + async create(input: CreateEmployeeInput): Promise { + const now = DateTime.fromUnixMs(Date.now()); + const status = input.status ?? Status.create(Status.DEFAULT); + try { + const inserted = await this.db + .insert(employees) + .values(this.toInsertValues(input, status, now, input.userId)) + .returning(); + const row = inserted[0]; + return this.toDomain(row); + } catch (error) { + this.rethrowUniqueViolation(error); + } + } + + async createMany(inputs: CreateEmployeeInput[]): Promise { + if (inputs.length === 0) { + return 0; + } + const now = DateTime.fromUnixMs(Date.now()); + try { + await this.db.transaction(async (tx) => { + for (const input of inputs) { + const status = input.status ?? Status.create(Status.DEFAULT); + await tx + .insert(employees) + .values(this.toInsertValues(input, status, now, input.userId)); + } + }); + return inputs.length; + } catch (error) { + this.rethrowUniqueViolation(error); + } + } + + async update(id: string, input: UpdateEmployeeInput): Promise { + const existing = await this.findById(id); + if (!existing) { + throw new NotFoundException('Employee not found'); + } + const now = DateTime.fromUnixMs(Date.now()); + try { + const updated = await this.db + .update(employees) + .set({ + code: input.code ?? existing.code, + name: input.name ?? existing.name, + phone: input.phone?.value ?? existing.phone.value, + position: input.position ?? existing.position, + updatedAt: now.value, + updatedBy: input.userId, + }) + .where(eq(employees.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 { + const now = DateTime.fromUnixMs(Date.now()); + const updated = await this.db + .update(employees) + .set({ + status: status.value, + updatedAt: now.value, + updatedBy: userId, + }) + .where(eq(employees.id, id)) + .returning(); + const row = updated[0]; + if (!row) { + throw new NotFoundException('Employee not found'); + } + return this.toDomain(row); + } + + async bulkUpdateStatus( + ids: string[], + status: Status, + userId: string, + ): Promise { + if (ids.length === 0) { + return 0; + } + const now = DateTime.fromUnixMs(Date.now()); + const rows = await this.db + .update(employees) + .set({ + status: status.value, + updatedAt: now.value, + updatedBy: userId, + }) + .where(inArray(employees.id, ids)) + .returning({ id: employees.id }); + return rows.length; + } + + async delete(id: string): Promise { + const deleted = await this.db + .delete(employees) + .where(eq(employees.id, id)) + .returning({ id: employees.id }); + if (deleted.length === 0) { + throw new NotFoundException('Employee not found'); + } + } + + async bulkDelete(ids: string[]): Promise { + if (ids.length === 0) { + return 0; + } + const deleted = await this.db + .delete(employees) + .where(inArray(employees.id, ids)) + .returning({ id: employees.id }); + return deleted.length; + } + + private buildListWhere(filters: ListEmployeesFilters): SQL | undefined { + const parts: SQL[] = []; + if (filters.code) { + parts.push(ilike(employees.code, `%${filters.code}%`)); + } + if (filters.name) { + parts.push(ilike(employees.name, `%${filters.name}%`)); + } + if (filters.phone) { + parts.push(ilike(employees.phone, `%${filters.phone}%`)); + } + if (filters.position) { + parts.push(eq(employees.position, filters.position)); + } + if (filters.status) { + parts.push(eq(employees.status, filters.status)); + } + if (filters.search) { + const search = or( + ilike(employees.code, `%${filters.search}%`), + ilike(employees.name, `%${filters.search}%`), + ); + if (search) { + parts.push(search); + } + } + if (parts.length === 0) { + return undefined; + } + return parts.length === 1 ? parts[0] : and(...parts); + } + + private toInsertValues( + input: CreateEmployeeInput, + status: Status, + now: DateTime, + userId: string, + ) { + return { + code: input.code, + name: input.name, + phone: input.phone.value, + position: input.position, + status: status.value, + createdAt: now.value, + updatedAt: now.value, + createdBy: userId, + updatedBy: userId, + }; + } + + private toDomain(row: EmployeeRow): Employee { + return { + id: row.id, + code: row.code, + name: row.name, + phone: PhoneNumber.create(row.phone), + position: row.position as EmployeePosition, + 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 = this.unwrapDbError(error); + if (err.code === '23505') { + throw new ConflictException('Employee code already exists'); + } + throw error; + } + + private unwrapDbError(error: unknown): { code?: string } { + let current: unknown = error; + for (let i = 0; i < 5; i++) { + if (!current || typeof current !== 'object') { + break; + } + const obj = current as { code?: string; cause?: unknown }; + if (obj.code === '23505' || obj.code === '23503') { + return { code: obj.code }; + } + current = obj.cause; + } + return error as { code?: string }; + } +} diff --git a/src/modules/configuration/employees/employees.service.spec.ts b/src/modules/configuration/employees/employees.service.spec.ts new file mode 100644 index 0000000..8495cff --- /dev/null +++ b/src/modules/configuration/employees/employees.service.spec.ts @@ -0,0 +1,294 @@ +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 { Employee } from './employee'; +import { EmployeesRepository } from './employees.repository'; +import { EmployeesService } from './employees.service'; + +describe('EmployeesService', () => { + let service: EmployeesService; + let repository: jest.Mocked< + Pick< + EmployeesRepository, + | 'list' + | 'findById' + | 'create' + | 'createMany' + | 'update' + | 'updateStatus' + | 'bulkUpdateStatus' + | 'delete' + | 'bulkDelete' + > + >; + + const now = DateTime.fromUnixMs(1_700_000_000_000); + const sample: Employee = { + id: 'emp-1', + code: 'EMP_01', + name: 'Ada Lovelace', + phone: PhoneNumber.create('+6281234567890'), + position: 'sales', + status: Status.create('draft'), + createdAt: now, + updatedAt: now, + createdBy: 'user-1', + updatedBy: 'user-1', + }; + + const createInput = { + code: 'EMP_01', + name: 'Ada Lovelace', + phone: '+6281234567890', + position: 'sales', + 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: [ + EmployeesService, + { provide: EmployeesRepository, useValue: repository }, + ], + }).compile(); + + service = moduleRef.get(EmployeesService); + }); + + it('list maps visible fields including phone and position', 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: 'emp-1', + code: 'EMP_01', + name: 'Ada Lovelace', + phone: '+6281234567890', + position: 'sales', + status: 'draft', + createdAt: now.value, + }); + expect(service.visibleFields).toEqual( + expect.arrayContaining(['phone', 'position', 'status']), + ); + }); + + 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('emp-1'); + expect(result.id).toBe('emp-1'); + expect(result.phone).toBe('+6281234567890'); + expect(result.position).toBe('sales'); + }); + + it('create defaults status to draft and maps 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.position).toBe('sales'); + }); + + it('create uses provided status', async () => { + repository.create.mockResolvedValue(sample); + await service.create({ ...createInput, status: 'active' }); + const arg = repository.create.mock.calls[0][0]; + expect(arg.status?.value).toBe('active'); + }); + + 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 name, code, or position', async () => { + await expect( + service.create({ ...createInput, name: 'Ada1' }), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + service.create({ ...createInput, code: 'EMP 01' }), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + service.create({ ...createInput, position: 'pilot' }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(repository.create).not.toHaveBeenCalled(); + }); + + it('update rejects status field', async () => { + await expect( + service.update('emp-1', { status: 'active', userId: 'user-1' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('update trims and validates fields', async () => { + repository.update.mockResolvedValue(sample); + await service.update('emp-1', { + name: 'Jean Luc', + code: 'EMP_02', + phone: '+6281234567891', + position: 'driver', + userId: 'user-1', + }); + expect(repository.update).toHaveBeenCalledWith( + 'emp-1', + expect.objectContaining({ + name: 'Jean Luc', + code: 'EMP_02', + position: 'driver', + }), + ); + }); + + it('update rejects invalid name, code, phone, or position', async () => { + await expect( + service.update('emp-1', { name: 'Ops1', userId: 'user-1' }), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + service.update('emp-1', { code: 'OPS 1', userId: 'user-1' }), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + service.update('emp-1', { phone: '0812', userId: 'user-1' }), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + service.update('emp-1', { position: 'pilot', userId: 'user-1' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('updateStatus updates via repository', async () => { + repository.updateStatus.mockResolvedValue(sample); + await service.updateStatus('emp-1', 'active', 'user-1'); + expect(repository.updateStatus).toHaveBeenCalledWith( + 'emp-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('emp-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 result = await service.importCsv( + 'code,name,phone,position,status\nEMP_01,Ada Lovelace,+6281234567890,sales,draft', + 'user-1', + ); + expect(result.imported).toBe(1); + expect(repository.createMany).toHaveBeenCalledTimes(1); + const row = repository.createMany.mock.calls[0][0][0]; + expect(row.phone.value).toBe('+6281234567890'); + expect(row.position).toBe('sales'); + }); + + it('importCsv fails the batch on invalid phone', async () => { + await expect( + service.importCsv( + 'code,name,phone,position\nEMP_01,Ada Lovelace,081234,sales', + 'user-1', + ), + ).rejects.toBeInstanceOf(BadRequestException); + expect(repository.createMany).not.toHaveBeenCalled(); + }); + + it('importCsv fails batch on invalid name, code, or position', async () => { + await expect( + service.importCsv( + 'code,name,phone,position\nEMP_01,Ada1,+6281234567890,sales', + 'user-1', + ), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + service.importCsv( + 'code,name,phone,position\nEMP 01,Ada Lovelace,+6281234567890,sales', + 'user-1', + ), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + service.importCsv( + 'code,name,phone,position\nEMP_01,Ada Lovelace,+6281234567890,pilot', + 'user-1', + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('importCsv rejects empty, oversized, and headerless files', async () => { + await expect(service.importCsv('', 'user-1')).rejects.toBeInstanceOf( + BadRequestException, + ); + await expect( + service.importCsv('code,name\nEMP_01,Ada', 'user-1'), + ).rejects.toBeInstanceOf(BadRequestException); + const huge = [ + 'code,name,phone,position', + ...Array.from( + { length: 501 }, + (_, i) => `E${i},Ada Lovelace,+6281234567890,sales`, + ), + ].join('\n'); + await expect(service.importCsv(huge, 'user-1')).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('importCsv rejects missing required cells', async () => { + await expect( + service.importCsv( + 'code,name,phone,position\n,Ada Lovelace,+6281234567890,sales', + 'user-1', + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('importCsv rejects invalid status without echoing it', async () => { + await expect( + service.importCsv( + 'code,name,phone,position,status\nEMP_01,Ada Lovelace,+6281234567890,sales,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('emp-1', { userId: 'user-1' }); + expect(repository.update).toHaveBeenCalledWith( + 'emp-1', + expect.objectContaining({ userId: 'user-1' }), + ); + }); +}); diff --git a/src/modules/configuration/employees/employees.service.ts b/src/modules/configuration/employees/employees.service.ts new file mode 100644 index 0000000..72adc2d --- /dev/null +++ b/src/modules/configuration/employees/employees.service.ts @@ -0,0 +1,295 @@ +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 { + CreateEmployeeInput, + Employee, + UpdateEmployeeInput, +} from './employee'; +import { + isValidEmployeeCode, + isValidEmployeeName, + isValidEmployeePosition, + parseCsvRecord, + type EmployeePosition, +} from './employee-fields'; +import { EmployeesRepository } from './employees.repository'; + +export type ListEmployeesQuery = { + readonly code?: string; + readonly name?: string; + readonly phone?: string; + readonly position?: string; + readonly status?: string; + readonly search?: string; + readonly page?: number; + readonly limit?: number; + readonly offset?: number; +}; + +const VISIBLE_FIELDS = [ + 'id', + 'code', + 'name', + 'phone', + 'position', + 'status', + 'createdAt', + 'updatedAt', + 'createdBy', + 'updatedBy', +] as const; + +const CSV_REQUIRED_HEADERS = ['code', 'name', 'phone', 'position'] as const; + +@Injectable() +export class EmployeesService { + constructor(private readonly employeesRepository: EmployeesRepository) {} + + async list( + query: ListEmployeesQuery, + ): Promise>> { + const page = toListPage(query); + const { data, total } = await this.employeesRepository.list({ + code: query.code, + name: query.name, + phone: query.phone, + position: query.position, + 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> { + const employee = await this.employeesRepository.findById(id); + if (!employee) { + throw new NotFoundException('Employee not found'); + } + return this.toListItem(employee); + } + + async create(input: { + code: string; + name: string; + phone: string; + position: string; + status?: string; + userId: string; + }): Promise> { + const created = await this.employeesRepository.create( + this.toCreateInput(input), + ); + return this.toListItem(created); + } + + async update( + id: string, + input: { + code?: string; + name?: string; + phone?: string; + position?: string; + status?: unknown; + userId: string; + }, + ): Promise> { + if (input.status !== undefined) { + throw new BadRequestException('status cannot be updated via PATCH'); + } + const payload: UpdateEmployeeInput = { + 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, + position: + input.position !== undefined + ? this.assertPosition(input.position) + : undefined, + userId: input.userId, + }; + const updated = await this.employeesRepository.update(id, payload); + return this.toListItem(updated); + } + + async updateStatus( + id: string, + statusRaw: string, + userId: string, + ): Promise> { + const status = Status.create(statusRaw); + const updated = await this.employeesRepository.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.employeesRepository.bulkUpdateStatus( + ids, + status, + userId, + ); + return { updated }; + } + + async delete(id: string): Promise { + await this.employeesRepository.delete(id); + } + + async bulkDelete(ids: string[]): Promise<{ deleted: number }> { + const deleted = await this.employeesRepository.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: CreateEmployeeInput[] = []; + 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')] : ''; + rows.push( + this.toCreateInput({ + code: cols[idx('code')] ?? '', + name: cols[idx('name')] ?? '', + phone: cols[idx('phone')] ?? '', + position: cols[idx('position')] ?? '', + 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.employeesRepository.createMany(rows); + return { imported: rows.length }; + } + + toListItem(employee: Employee) { + return { + id: employee.id, + code: employee.code, + name: employee.name, + phone: employee.phone.value, + position: employee.position, + status: employee.status.value, + createdAt: employee.createdAt.value, + updatedAt: employee.updatedAt.value, + createdBy: employee.createdBy, + updatedBy: employee.updatedBy, + }; + } + + get visibleFields(): readonly string[] { + return VISIBLE_FIELDS; + } + + private toCreateInput(input: { + code: string; + name: string; + phone: string; + position: string; + status?: string; + userId: string; + }): CreateEmployeeInput { + return { + code: this.assertCode(input.code), + name: this.assertName(input.name), + phone: this.assertPhone(input.phone), + position: this.assertPosition(input.position), + status: input.status + ? Status.create(input.status) + : Status.create(Status.DEFAULT), + userId: input.userId, + }; + } + + private assertName(raw: string): string { + const name = raw.trim(); + if (!isValidEmployeeName(name)) { + throw new BadRequestException('Invalid employee name'); + } + return name; + } + + private assertCode(raw: string): string { + const code = raw.trim(); + if (!isValidEmployeeCode(code)) { + throw new BadRequestException('Invalid employee code'); + } + return code; + } + + private assertPosition(raw: string): EmployeePosition { + const position = raw.trim(); + if (!isValidEmployeePosition(position)) { + throw new BadRequestException('Invalid employee position'); + } + return position; + } + + private assertPhone(raw: string): PhoneNumber { + try { + return PhoneNumber.create(raw); + } catch (error) { + if (error instanceof InvalidPhoneNumberError) { + throw new BadRequestException('Invalid phone number'); + } + throw error; + } + } +} diff --git a/test/employees.e2e-spec.ts b/test/employees.e2e-spec.ts new file mode 100644 index 0000000..467dbee --- /dev/null +++ b/test/employees.e2e-spec.ts @@ -0,0 +1,228 @@ +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('Employees (e2e)', () => { + let app: INestApplication; + let db: DrizzleDB; + + const password = 'password123'; + const adminUsername = `emp_admin_${Date.now()}`; + const otherUsername = `emp_other_${Date.now()}`; + + let adminAccessToken: string; + let adminUserId: string; + let otherAccessToken: string; + + const payload = { + code: `EMP_${Date.now().toString().slice(-6)}`, + name: 'Ada Lovelace', + phone: '+6281234567890', + position: 'sales', + }; + + 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: 'Employee Admin', + code: `EMP_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 employees list without permission', async () => { + await request(app.getHttpServer()) + .get('/employees') + .set('Authorization', `Bearer ${otherAccessToken}`) + .expect(403); + }); + + it('rejects unauthenticated access', async () => { + await request(app.getHttpServer()).get('/employees').expect(401); + }); + + it('CRUD employees with name/code/phone/position rules, status, search, and bulk', async () => { + const created = await request(app.getHttpServer()) + .post('/employees') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send(payload) + .expect(201); + + expect(created.body).toMatchObject({ + code: payload.code, + name: 'Ada Lovelace', + phone: '+6281234567890', + position: 'sales', + status: 'draft', + createdBy: adminUserId, + }); + const id = (created.body as { id: string }).id; + + await request(app.getHttpServer()) + .post('/employees') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ ...payload, code: 'BAD 01', name: 'Jean Luc' }) + .expect(400); + + await request(app.getHttpServer()) + .post('/employees') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ ...payload, code: 'PHN_01', phone: '081234567890' }) + .expect(400); + + await request(app.getHttpServer()) + .post('/employees') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ ...payload, code: 'POS_01', position: 'pilot' }) + .expect(400); + + await request(app.getHttpServer()) + .post('/employees') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ ...payload, code: payload.code, name: 'Jean Luc' }) + .expect(409); + + await request(app.getHttpServer()) + .get(`/employees/${id}`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .expect(200); + + await request(app.getHttpServer()) + .patch(`/employees/${id}`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ name: 'Jean Luc', position: 'driver' }) + .expect(200); + + await request(app.getHttpServer()) + .patch(`/employees/${id}`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ status: 'active' }) + .expect(400); + + await request(app.getHttpServer()) + .patch(`/employees/${id}/status`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ status: 'active' }) + .expect(200); + + const list = await request(app.getHttpServer()) + .get('/employees?search=Jean') + .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('/employees') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ + ...payload, + code: `OTH_${Date.now().toString().slice(-6)}`, + name: 'Grace Hopper', + position: 'crew', + }) + .expect(201); + + await request(app.getHttpServer()) + .post('/employees/bulk-status') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ ids: [(extra.body as { id: string }).id], status: 'archived' }) + .expect(200); + + await request(app.getHttpServer()) + .post('/employees/bulk-delete') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ ids: [(extra.body as { id: string }).id] }) + .expect(200); + + await request(app.getHttpServer()) + .delete(`/employees/${id}`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .expect(204); + }); + + it('imports employees from CSV', async () => { + const suffix = Date.now().toString().slice(-6); + const csv = + 'code,name,phone,position,status\n' + + `IMP_${suffix},Imported Employee,+6281234567890,crew,draft\n`; + const res = await request(app.getHttpServer()) + .post('/employees/import') + .set('Authorization', `Bearer ${adminAccessToken}`) + .attach('file', Buffer.from(csv, 'utf8'), 'employees.csv') + .expect(201); + + expect(res.body).toMatchObject({ imported: 1 }); + }); +});