diff --git a/drizzle/migrations/0006_customers.sql b/drizzle/migrations/0006_customers.sql new file mode 100644 index 0000000..f38a2df --- /dev/null +++ b/drizzle/migrations/0006_customers.sql @@ -0,0 +1,35 @@ +CREATE TABLE "customers" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "code" varchar(16) NOT NULL, + "name" varchar(64) NOT NULL, + "phone" text NOT NULL, + "address" text NOT NULL, + "latitude" double precision, + "longitude" double precision, + "nfc_id" text, + "status" text DEFAULT 'draft' NOT NULL, + "created_at" bigint NOT NULL, + "updated_at" bigint NOT NULL, + "created_by" uuid NOT NULL, + "updated_by" uuid NOT NULL +); +--> statement-breakpoint +CREATE TABLE "customer_contacts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "customer_id" uuid NOT NULL, + "name" varchar(64) NOT NULL, + "job_title" varchar(64), + "phone" text, + "mobile_phone" text, + "notes" text +); +--> statement-breakpoint +ALTER TABLE "customers" ADD CONSTRAINT "customers_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 "customers" ADD CONSTRAINT "customers_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "customer_contacts" ADD CONSTRAINT "customer_contacts_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "customers_code_unique" ON "customers" USING btree ("code");--> statement-breakpoint +CREATE UNIQUE INDEX "customers_nfc_id_unique" ON "customers" USING btree ("nfc_id");--> statement-breakpoint +CREATE INDEX "customer_contacts_customer_id_idx" ON "customer_contacts" USING btree ("customer_id"); +--> statement-breakpoint +INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES + ('CONFIGURATION.CUSTOMER', 'Customers', 5); diff --git a/drizzle/migrations/meta/0006_snapshot.json b/drizzle/migrations/meta/0006_snapshot.json new file mode 100644 index 0000000..d3b68df --- /dev/null +++ b/drizzle/migrations/meta/0006_snapshot.json @@ -0,0 +1,1050 @@ +{ + "id": "c7e0f438-ef05-5613-b956-0a7b1eg58176", + "prevId": "b6dfe327-def4-4502-a845-9f6a0df47065", + "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 + } + }, + "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 3ce35ff..83b3b43 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1787549883658, "tag": "0005_past_vengeance", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1787554000000, + "tag": "0006_customers", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/database/customers-table.ts b/src/database/customers-table.ts new file mode 100644 index 0000000..a0a39be --- /dev/null +++ b/src/database/customers-table.ts @@ -0,0 +1,58 @@ +import { + doublePrecision, + index, + pgTable, + text, + uniqueIndex, + uuid, + varchar, +} from 'drizzle-orm/pg-core'; +import { primaryEntityColumns } from './primary-entity-columns'; +import { users } from './schema'; + +/** + * Customers (primary aggregate). + * Kept in a separate module so Drizzle's table type stays resolvable. + */ +export const customers = pgTable( + 'customers', + { + id: uuid('id').defaultRandom().notNull().primaryKey(), + code: varchar('code', { length: 16 }).notNull(), + name: varchar('name', { length: 64 }).notNull(), + phone: text('phone').notNull(), + address: text('address').notNull(), + latitude: doublePrecision('latitude'), + longitude: doublePrecision('longitude'), + nfcId: text('nfc_id'), + ...primaryEntityColumns(users), + }, + (t) => [ + uniqueIndex('customers_code_unique').on(t.code), + uniqueIndex('customers_nfc_id_unique').on(t.nfcId), + ], +); + +/** + * Customer contacts (child rows). Cascade with the parent customer. + */ +export const customerContacts = pgTable( + 'customer_contacts', + { + id: uuid('id').defaultRandom().notNull().primaryKey(), + customerId: uuid('customer_id') + .notNull() + .references(() => customers.id, { onDelete: 'cascade' }), + name: varchar('name', { length: 64 }).notNull(), + jobTitle: varchar('job_title', { length: 64 }), + phone: text('phone'), + mobilePhone: text('mobile_phone'), + notes: text('notes'), + }, + (t) => [index('customer_contacts_customer_id_idx').on(t.customerId)], +); + +export type CustomerRow = typeof customers.$inferSelect; +export type NewCustomerRow = typeof customers.$inferInsert; +export type CustomerContactRow = typeof customerContacts.$inferSelect; +export type NewCustomerContactRow = typeof customerContacts.$inferInsert; diff --git a/src/database/schema.ts b/src/database/schema.ts index 82f1609..f85680c 100644 --- a/src/database/schema.ts +++ b/src/database/schema.ts @@ -143,3 +143,11 @@ export type DivisionRow = typeof divisions.$inferSelect; export type NewDivisionRow = typeof divisions.$inferInsert; export { branches, type BranchRow, type NewBranchRow } from './branches-table'; +export { + customerContacts, + customers, + type CustomerContactRow, + type CustomerRow, + type NewCustomerContactRow, + type NewCustomerRow, +} from './customers-table'; diff --git a/src/modules/configuration/configuration.module.ts b/src/modules/configuration/configuration.module.ts index 1c98f17..4238763 100644 --- a/src/modules/configuration/configuration.module.ts +++ b/src/modules/configuration/configuration.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; import { BranchesModule } from './branches/branches.module'; +import { CustomersModule } from './customers/customers.module'; import { DivisionsModule } from './divisions/divisions.module'; @Module({ - imports: [DivisionsModule, BranchesModule], - exports: [DivisionsModule, BranchesModule], + imports: [DivisionsModule, BranchesModule, CustomersModule], + exports: [DivisionsModule, BranchesModule, CustomersModule], }) export class ConfigurationModule {} diff --git a/src/modules/configuration/customers/customer-fields.spec.ts b/src/modules/configuration/customers/customer-fields.spec.ts new file mode 100644 index 0000000..8b7d617 --- /dev/null +++ b/src/modules/configuration/customers/customer-fields.spec.ts @@ -0,0 +1,172 @@ +import { + CONTACT_NAME_MAX_LENGTH, + CUSTOMER_CODE_MAX_LENGTH, + CUSTOMER_NAME_MAX_LENGTH, + isAllowedCsvUpload, + isValidContactJobTitle, + isValidContactName, + isValidContactNotes, + isValidCustomerAddress, + isValidCustomerCode, + isValidCustomerName, + isValidLatitude, + isValidLongitude, + isValidNfcId, + parseCsvRecord, +} from './customer-fields'; + +describe('customer fields', () => { + describe('isValidCustomerName', () => { + it.each(['Acme', 'South Jakarta', 'A', 'North West Region'])( + 'accepts %s', + (name) => { + expect(isValidCustomerName(name)).toBe(true); + }, + ); + + it.each([ + '', + 'Acme1', + 'South-Jakarta', + 'CUST_01', + ' Acme', + 'Acme ', + 'South Jakarta', + ])('rejects %s', (name) => { + expect(isValidCustomerName(name)).toBe(false); + }); + + it('rejects names longer than 64 characters', () => { + expect( + isValidCustomerName('A'.repeat(CUSTOMER_NAME_MAX_LENGTH + 1)), + ).toBe(false); + expect(isValidCustomerName('A'.repeat(CUSTOMER_NAME_MAX_LENGTH))).toBe( + true, + ); + }); + }); + + describe('isValidCustomerCode', () => { + it.each(['CUST', 'CUST_01', 'A', 'ops2', 'A_b_1'])('accepts %s', (code) => { + expect(isValidCustomerCode(code)).toBe(true); + }); + + it.each(['', 'CUST 01', 'CUST-01', 'CUST.01', ' CUST', 'CUST '])( + 'rejects %s', + (code) => { + expect(isValidCustomerCode(code)).toBe(false); + }, + ); + + it('rejects codes longer than 16 characters', () => { + expect( + isValidCustomerCode('A'.repeat(CUSTOMER_CODE_MAX_LENGTH + 1)), + ).toBe(false); + expect(isValidCustomerCode('A'.repeat(CUSTOMER_CODE_MAX_LENGTH))).toBe( + true, + ); + }); + }); + + describe('isValidCustomerAddress', () => { + it('accepts a non-empty address', () => { + expect(isValidCustomerAddress('Jl Sudirman No 1')).toBe(true); + }); + + it('rejects empty or oversized addresses', () => { + expect(isValidCustomerAddress('')).toBe(false); + expect(isValidCustomerAddress('A'.repeat(256))).toBe(false); + }); + }); + + describe('coordinates', () => { + it('accepts latitude and longitude in range', () => { + expect(isValidLatitude(-90)).toBe(true); + expect(isValidLatitude(90)).toBe(true); + expect(isValidLongitude(-180)).toBe(true); + expect(isValidLongitude(180)).toBe(true); + }); + + it('rejects out of range coordinates', () => { + expect(isValidLatitude(-90.1)).toBe(false); + expect(isValidLatitude(90.1)).toBe(false); + expect(isValidLongitude(-180.1)).toBe(false); + expect(isValidLongitude(180.1)).toBe(false); + expect(isValidLatitude(Number.NaN)).toBe(false); + }); + }); + + describe('isValidNfcId', () => { + it('accepts a non-empty NFC id', () => { + expect(isValidNfcId('NFC-001')).toBe(true); + }); + + it('rejects empty NFC id', () => { + expect(isValidNfcId('')).toBe(false); + }); + }); + + describe('isValidContactName', () => { + it.each(["O'Brien", 'Jean-Luc', 'A', 'Li Wei'])('accepts %s', (name) => { + expect(isValidContactName(name)).toBe(true); + }); + + it('rejects empty or oversized names', () => { + expect(isValidContactName('')).toBe(false); + expect(isValidContactName(' ')).toBe(false); + expect(isValidContactName('A'.repeat(CONTACT_NAME_MAX_LENGTH + 1))).toBe( + false, + ); + }); + }); + + describe('isValidContactJobTitle and notes', () => { + it('accepts optional job title and notes within limits', () => { + expect(isValidContactJobTitle('Purchasing Manager')).toBe(true); + expect(isValidContactNotes('Call after 9am')).toBe(true); + }); + + it('rejects oversized job title or notes', () => { + expect(isValidContactJobTitle('A'.repeat(65))).toBe(false); + expect(isValidContactNotes('A'.repeat(256))).toBe(false); + }); + }); + + describe('parseCsvRecord', () => { + it('keeps commas inside quoted fields', () => { + expect( + parseCsvRecord('CUST_01,Acme Corp,"Jl Sudirman No 1, Blok A"'), + ).toEqual(['CUST_01', 'Acme Corp', 'Jl Sudirman No 1, Blok A']); + }); + + it('unescapes doubled quotes', () => { + expect(parseCsvRecord('"Say ""hello""",x')).toEqual(['Say "hello"', 'x']); + }); + }); + + describe('isAllowedCsvUpload', () => { + it('accepts csv mime or .csv names', () => { + expect( + isAllowedCsvUpload({ + mimetype: 'text/csv', + originalname: 'x.txt', + }), + ).toBe(true); + expect( + isAllowedCsvUpload({ + mimetype: 'application/octet-stream', + originalname: 'customers.csv', + }), + ).toBe(true); + }); + + it('rejects non-csv files', () => { + expect( + isAllowedCsvUpload({ + mimetype: 'application/pdf', + originalname: 'x.pdf', + }), + ).toBe(false); + }); + }); +}); diff --git a/src/modules/configuration/customers/customer-fields.ts b/src/modules/configuration/customers/customer-fields.ts new file mode 100644 index 0000000..243ecff --- /dev/null +++ b/src/modules/configuration/customers/customer-fields.ts @@ -0,0 +1,112 @@ +export const CUSTOMER_NAME_MAX_LENGTH = 64; +export const CUSTOMER_CODE_MAX_LENGTH = 16; +export const CUSTOMER_ADDRESS_MAX_LENGTH = 255; +export const CUSTOMER_NFC_ID_MAX_LENGTH = 64; +export const CONTACT_NAME_MAX_LENGTH = 64; +export const CONTACT_JOB_TITLE_MAX_LENGTH = 64; +export const CONTACT_NOTES_MAX_LENGTH = 255; + +/** Letters with single spaces between words. */ +export const CUSTOMER_NAME_PATTERN = /^[A-Za-z]+(?: [A-Za-z]+)*$/; + +/** Alphanumeric and underscore; no spaces. */ +export const CUSTOMER_CODE_PATTERN = /^[A-Za-z0-9_]+$/; + +export function isValidCustomerName(raw: string): boolean { + return ( + typeof raw === 'string' && + raw.length > 0 && + raw.length <= CUSTOMER_NAME_MAX_LENGTH && + CUSTOMER_NAME_PATTERN.test(raw) + ); +} + +export function isValidCustomerCode(raw: string): boolean { + return ( + typeof raw === 'string' && + raw.length > 0 && + raw.length <= CUSTOMER_CODE_MAX_LENGTH && + CUSTOMER_CODE_PATTERN.test(raw) + ); +} + +export function isValidCustomerAddress(raw: string): boolean { + return ( + typeof raw === 'string' && + raw.length > 0 && + raw.length <= CUSTOMER_ADDRESS_MAX_LENGTH + ); +} + +export function isValidLatitude(raw: number): boolean { + return Number.isFinite(raw) && raw >= -90 && raw <= 90; +} + +export function isValidLongitude(raw: number): boolean { + return Number.isFinite(raw) && raw >= -180 && raw <= 180; +} + +export function isValidNfcId(raw: string): boolean { + return ( + typeof raw === 'string' && + raw.length > 0 && + raw.length <= CUSTOMER_NFC_ID_MAX_LENGTH + ); +} + +export function isValidContactName(raw: string): boolean { + return ( + typeof raw === 'string' && + raw.trim().length > 0 && + raw.trim().length <= CONTACT_NAME_MAX_LENGTH + ); +} + +export function isValidContactJobTitle(raw: string): boolean { + return typeof raw === 'string' && raw.length <= CONTACT_JOB_TITLE_MAX_LENGTH; +} + +export function isValidContactNotes(raw: string): boolean { + return typeof raw === 'string' && raw.length <= CONTACT_NOTES_MAX_LENGTH; +} + +/** 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/customers/customer.ts b/src/modules/configuration/customers/customer.ts new file mode 100644 index 0000000..e7227a9 --- /dev/null +++ b/src/modules/configuration/customers/customer.ts @@ -0,0 +1,84 @@ +import { DateTime } from '../../../common/value-objects/date-time/date-time'; +import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number'; +import { Status } from '../../../common/value-objects/status/status'; + +export type CustomerContact = { + readonly id: string; + readonly customerId: string; + readonly name: string; + readonly jobTitle: string | null; + readonly phone: PhoneNumber | null; + readonly mobilePhone: PhoneNumber | null; + readonly notes: string | null; +}; + +export type Customer = { + readonly id: string; + readonly code: string; + readonly name: string; + readonly phone: PhoneNumber; + readonly address: string; + readonly latitude: number | null; + readonly longitude: number | null; + readonly nfcId: string | null; + readonly contacts: readonly CustomerContact[]; + readonly status: Status; + readonly createdAt: DateTime; + readonly updatedAt: DateTime; + readonly createdBy: string; + readonly updatedBy: string; +}; + +export type CustomerContactInput = { + readonly name: string; + readonly jobTitle?: string | null; + readonly phone?: PhoneNumber | null; + readonly mobilePhone?: PhoneNumber | null; + readonly notes?: string | null; +}; + +export type CreateCustomerInput = { + readonly code: string; + readonly name: string; + readonly phone: PhoneNumber; + readonly address: string; + readonly latitude?: number | null; + readonly longitude?: number | null; + readonly nfcId?: string | null; + readonly contacts?: readonly CustomerContactInput[]; + readonly status?: Status; + readonly userId: string; +}; + +export type UpdateCustomerInput = { + readonly code?: string; + readonly name?: string; + readonly phone?: PhoneNumber; + readonly address?: string; + readonly latitude?: number | null; + readonly longitude?: number | null; + readonly nfcId?: string | null; + readonly contacts?: readonly CustomerContactInput[]; + readonly userId: string; +}; + +export type UpdateCustomerContactInput = { + readonly name?: string; + readonly jobTitle?: string | null; + readonly phone?: PhoneNumber | null; + readonly mobilePhone?: PhoneNumber | null; + readonly notes?: string | null; + readonly userId: string; +}; + +export type ListCustomersFilters = { + readonly code?: string; + readonly name?: string; + readonly phone?: string; + readonly address?: string; + readonly nfcId?: string; + readonly status?: string; + readonly search?: string; + readonly limit: number; + readonly offset: number; +}; diff --git a/src/modules/configuration/customers/customers-read.controller.spec.ts b/src/modules/configuration/customers/customers-read.controller.spec.ts new file mode 100644 index 0000000..22f80e0 --- /dev/null +++ b/src/modules/configuration/customers/customers-read.controller.spec.ts @@ -0,0 +1,34 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CustomersReadController } from './customers-read.controller'; +import { CustomersService } from './customers.service'; + +describe('CustomersReadController', () => { + let controller: CustomersReadController; + const service = { + list: jest.fn(), + findById: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + const moduleRef: TestingModule = await Test.createTestingModule({ + controllers: [CustomersReadController], + providers: [{ provide: CustomersService, useValue: service }], + }).compile(); + controller = moduleRef.get(CustomersReadController); + }); + + 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: 'cu-1' }); + await expect(controller.findOne('cu-1')).resolves.toEqual({ id: 'cu-1' }); + }); +}); diff --git a/src/modules/configuration/customers/customers-read.controller.ts b/src/modules/configuration/customers/customers-read.controller.ts new file mode 100644 index 0000000..59442e6 --- /dev/null +++ b/src/modules/configuration/customers/customers-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 { CustomerDto, ListCustomersQueryDto } from './dto/customer.dto'; +import { CustomersService } from './customers.service'; + +export const CUSTOMER_PRIVILEGE_KEY = 'CONFIGURATION.CUSTOMER'; + +@ApiTags('customers') +@ApiBearerAuth(BEARER_AUTH_NAME) +@Controller('customers') +export class CustomersReadController { + constructor(private readonly customersService: CustomersService) {} + + @Get() + @Pagination() + @RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'view') + @ApiOperation({ summary: 'List customers' }) + @ApiOkResponse({ + schema: { + properties: { + data: { + type: 'array', + items: { $ref: '#/components/schemas/CustomerDto' }, + }, + meta: { $ref: '#/components/schemas/PaginationMetaDto' }, + }, + }, + }) + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + list( + @Query() query: ListCustomersQueryDto, + ): Promise> { + return this.customersService.list(query); + } + + @Get(':id') + @RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'view') + @ApiOperation({ summary: 'Get customer detail' }) + @ApiOkResponse({ type: CustomerDto }) + @ApiNotFoundResponse() + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + findOne(@Param('id', ParseUUIDPipe) id: string): Promise { + return this.customersService.findById(id); + } +} + +void PaginationMetaDto; diff --git a/src/modules/configuration/customers/customers-write.controller.spec.ts b/src/modules/configuration/customers/customers-write.controller.spec.ts new file mode 100644 index 0000000..f692508 --- /dev/null +++ b/src/modules/configuration/customers/customers-write.controller.spec.ts @@ -0,0 +1,105 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CustomersWriteController } from './customers-write.controller'; +import { CustomersService } from './customers.service'; + +const createDto = { + code: 'CUST_01', + name: 'Acme Corp', + phone: '+6281234567890', + address: 'Jl Sudirman No 1', +}; + +describe('CustomersWriteController', () => { + let controller: CustomersWriteController; + const service = { + create: jest.fn(), + update: jest.fn(), + updateStatus: jest.fn(), + delete: jest.fn(), + bulkDelete: jest.fn(), + bulkUpdateStatus: jest.fn(), + importCsv: jest.fn(), + addContact: jest.fn(), + updateContact: jest.fn(), + deleteContact: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + const moduleRef: TestingModule = await Test.createTestingModule({ + controllers: [CustomersWriteController], + providers: [{ provide: CustomersService, useValue: service }], + }).compile(); + controller = moduleRef.get(CustomersWriteController); + }); + + it('create passes dto fields and user id', async () => { + service.create.mockResolvedValue({ id: 'cu-1' }); + await controller.create(createDto, 'user-1'); + expect(service.create).toHaveBeenCalledWith({ + ...createDto, + userId: 'user-1', + }); + }); + + it('update, updateStatus, and delete delegate', async () => { + service.update.mockResolvedValue({ id: 'cu-1' }); + service.updateStatus.mockResolvedValue({ id: 'cu-1' }); + service.delete.mockResolvedValue(undefined); + await controller.update('cu-1', { name: 'Acme Corp' }, 'user-1'); + await controller.updateStatus('cu-1', { status: 'active' }, 'user-1'); + await controller.delete('cu-1'); + expect(service.updateStatus).toHaveBeenCalledWith( + 'cu-1', + 'active', + 'user-1', + ); + expect(service.delete).toHaveBeenCalledWith('cu-1'); + }); + + it('nested contact routes delegate', async () => { + service.addContact.mockResolvedValue({ id: 'cu-1' }); + service.updateContact.mockResolvedValue({ id: 'cu-1' }); + service.deleteContact.mockResolvedValue(undefined); + await controller.addContact('cu-1', { name: 'Ada Lovelace' }, 'user-1'); + await controller.updateContact( + 'cu-1', + 'ct-1', + { jobTitle: 'Buyer' }, + 'user-1', + ); + await controller.deleteContact('cu-1', 'ct-1'); + expect(service.addContact).toHaveBeenCalledWith( + 'cu-1', + { name: 'Ada Lovelace' }, + 'user-1', + ); + expect(service.updateContact).toHaveBeenCalledWith('cu-1', 'ct-1', { + jobTitle: 'Buyer', + userId: 'user-1', + }); + expect(service.deleteContact).toHaveBeenCalledWith('cu-1', 'ct-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: ['cu-1'] }); + await controller.bulkStatus( + { ids: ['cu-1'], status: 'archived' }, + 'user-1', + ); + await controller.importCsv( + { buffer: Buffer.from('code,name\nCUST_01,Acme') }, + '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/customers/customers-write.controller.ts b/src/modules/configuration/customers/customers-write.controller.ts new file mode 100644 index 0000000..e29cf4e --- /dev/null +++ b/src/modules/configuration/customers/customers-write.controller.ts @@ -0,0 +1,227 @@ +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 './customer-fields'; +import { CUSTOMER_PRIVILEGE_KEY } from './customers-read.controller'; +import { CustomersService } from './customers.service'; +import { + BulkIdsDto, + BulkStatusDto, + CreateCustomerContactDto, + CreateCustomerDto, + CustomerDto, + UpdateCustomerContactDto, + UpdateCustomerDto, + UpdateCustomerStatusDto, +} from './dto/customer.dto'; + +@ApiTags('customers') +@ApiBearerAuth(BEARER_AUTH_NAME) +@Controller('customers') +export class CustomersWriteController { + constructor(private readonly customersService: CustomersService) {} + + @Post('import') + @RequirePrivilege(CUSTOMER_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 customers 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.customersService.importCsv(csv, userId); + } + + @Post('bulk-delete') + @HttpCode(200) + @RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'delete') + @ApiOperation({ summary: 'Bulk delete customers' }) + @ApiOkResponse({ + schema: { properties: { deleted: { type: 'number' } } }, + }) + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + bulkDelete(@Body() dto: BulkIdsDto): Promise<{ deleted: number }> { + return this.customersService.bulkDelete(dto.ids); + } + + @Post('bulk-status') + @HttpCode(200) + @RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update') + @ApiOperation({ summary: 'Bulk update customer status' }) + @ApiOkResponse({ + schema: { properties: { updated: { type: 'number' } } }, + }) + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + bulkStatus( + @Body() dto: BulkStatusDto, + @CurrentUser('id') userId: string, + ): Promise<{ updated: number }> { + return this.customersService.bulkUpdateStatus(dto.ids, dto.status, userId); + } + + @Post() + @RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'create') + @ApiOperation({ summary: 'Create customer' }) + @ApiCreatedResponse({ type: CustomerDto }) + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + create( + @Body() dto: CreateCustomerDto, + @CurrentUser('id') userId: string, + ): Promise { + return this.customersService.create({ + ...dto, + userId, + }); + } + + @Post(':id/contacts') + @RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update') + @ApiOperation({ summary: 'Add a customer contact' }) + @ApiOkResponse({ type: CustomerDto }) + @ApiNotFoundResponse() + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + addContact( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CreateCustomerContactDto, + @CurrentUser('id') userId: string, + ): Promise { + return this.customersService.addContact(id, dto, userId); + } + + @Patch(':id/contacts/:contactId') + @RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update') + @ApiOperation({ summary: 'Update a customer contact' }) + @ApiOkResponse({ type: CustomerDto }) + @ApiNotFoundResponse() + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + updateContact( + @Param('id', ParseUUIDPipe) id: string, + @Param('contactId', ParseUUIDPipe) contactId: string, + @Body() dto: UpdateCustomerContactDto, + @CurrentUser('id') userId: string, + ): Promise { + return this.customersService.updateContact(id, contactId, { + ...dto, + userId, + }); + } + + @Delete(':id/contacts/:contactId') + @HttpCode(204) + @RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update') + @ApiOperation({ summary: 'Delete a customer contact' }) + @ApiNoContentResponse() + @ApiNotFoundResponse() + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + async deleteContact( + @Param('id', ParseUUIDPipe) id: string, + @Param('contactId', ParseUUIDPipe) contactId: string, + ): Promise { + await this.customersService.deleteContact(id, contactId); + } + + @Patch(':id/status') + @RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update') + @ApiOperation({ summary: 'Update customer status' }) + @ApiOkResponse({ type: CustomerDto }) + @ApiNotFoundResponse() + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + updateStatus( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateCustomerStatusDto, + @CurrentUser('id') userId: string, + ): Promise { + return this.customersService.updateStatus(id, dto.status, userId); + } + + @Patch(':id') + @RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update') + @ApiOperation({ summary: 'Update customer (not status)' }) + @ApiOkResponse({ type: CustomerDto }) + @ApiNotFoundResponse() + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateCustomerDto, + @CurrentUser('id') userId: string, + ): Promise { + return this.customersService.update(id, { + ...dto, + userId, + }); + } + + @Delete(':id') + @HttpCode(204) + @RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'delete') + @ApiOperation({ summary: 'Delete customer' }) + @ApiNoContentResponse() + @ApiNotFoundResponse() + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + async delete(@Param('id', ParseUUIDPipe) id: string): Promise { + await this.customersService.delete(id); + } +} diff --git a/src/modules/configuration/customers/customers.module.ts b/src/modules/configuration/customers/customers.module.ts new file mode 100644 index 0000000..a062f80 --- /dev/null +++ b/src/modules/configuration/customers/customers.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { CustomersReadController } from './customers-read.controller'; +import { CustomersWriteController } from './customers-write.controller'; +import { CustomersRepository } from './customers.repository'; +import { CustomersService } from './customers.service'; + +@Module({ + controllers: [CustomersReadController, CustomersWriteController], + providers: [CustomersRepository, CustomersService], + exports: [CustomersService], +}) +export class CustomersModule {} diff --git a/src/modules/configuration/customers/customers.repository.spec.ts b/src/modules/configuration/customers/customers.repository.spec.ts new file mode 100644 index 0000000..831de41 --- /dev/null +++ b/src/modules/configuration/customers/customers.repository.spec.ts @@ -0,0 +1,232 @@ +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 { CustomersRepository } from './customers.repository'; + +describe('CustomersRepository', () => { + let repository: CustomersRepository; + + 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: 'cu-1', + code: 'CUST_01', + name: 'Acme Corp', + phone: '+6281234567890', + address: 'Jl Sudirman No 1', + latitude: -6.2, + longitude: 106.8, + nfcId: 'NFC-001', + status: 'draft', + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + createdBy: 'user-1', + updatedBy: 'user-1', + }; + + const contactRow = { + id: 'ct-1', + customerId: 'cu-1', + name: 'Jean Luc', + jobTitle: 'Buyer', + phone: '+6281234567891', + mobilePhone: null, + notes: 'Primary', + }; + + const createInput = { + code: 'CUST_01', + name: 'Acme Corp', + phone: PhoneNumber.create('+6281234567890'), + address: 'Jl Sudirman No 1', + 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: [CustomersRepository, { provide: DRIZZLE, useValue: db }], + }).compile(); + repository = moduleRef.get(CustomersRepository); + }); + + it('findById maps a row and contacts to domain', async () => { + limit.mockResolvedValueOnce([row]); + orderBy.mockResolvedValueOnce([contactRow]); + const customer = await repository.findById('cu-1'); + expect(customer).toMatchObject({ + id: 'cu-1', + code: 'CUST_01', + createdBy: 'user-1', + }); + expect(customer?.phone.value).toBe('+6281234567890'); + expect(customer?.status.value).toBe('draft'); + expect(customer?.contacts[0].name).toBe('Jean Luc'); + expect(customer?.contacts[0].phone?.value).toBe('+6281234567891'); + }); + + it('findById returns null when missing', async () => { + limit.mockResolvedValueOnce([]); + await expect(repository.findById('missing')).resolves.toBeNull(); + }); + + it('list returns mapped rows and total without loading contacts', 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: 'Acme', + code: 'CUST', + search: 'sudirman', + limit: 10, + offset: 0, + }); + expect(result.total).toBe(1); + expect(result.data[0].code).toBe('CUST_01'); + expect(result.data[0].contacts).toEqual([]); + }); + + it('create inserts and maps unique violations', async () => { + returning.mockResolvedValue([row]); + orderBy.mockResolvedValueOnce([]); + const created = await repository.create(createInput); + expect(created.code).toBe('CUST_01'); + + transaction.mockRejectedValueOnce({ code: '23505' }); + await expect(repository.create(createInput)).rejects.toBeInstanceOf( + ConflictException, + ); + + transaction.mockRejectedValueOnce({ + code: '23505', + constraint: 'customers_nfc_id_unique', + }); + await expect(repository.create(createInput)).rejects.toMatchObject({ + message: 'Customer NFC ID already exists', + }); + + transaction.mockRejectedValueOnce({ + cause: { + code: '23505', + constraint_name: 'customers_code_unique', + }, + }); + await expect(repository.create(createInput)).rejects.toMatchObject({ + message: 'Customer code already exists', + }); + }); + + it('create rethrows unknown errors', async () => { + transaction.mockRejectedValue(new Error('db down')); + await expect(repository.create(createInput)).rejects.toThrow('db down'); + }); + + it('createMany returns 0 for an empty batch', async () => { + await expect(repository.createMany([])).resolves.toBe(0); + }); + + it('update throws when missing', async () => { + limit.mockResolvedValueOnce([]); + await expect( + repository.update('missing', { userId: 'user-1' }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + 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('addContact throws when customer is missing', async () => { + limit.mockResolvedValueOnce([]); + await expect( + repository.addContact('missing', { name: 'Ada' }, 'user-1'), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('deleteContact throws when contact is missing', async () => { + returning.mockResolvedValueOnce([]); + await expect( + repository.deleteContact('cu-1', 'ct-1'), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + 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/customers/customers.repository.ts b/src/modules/configuration/customers/customers.repository.ts new file mode 100644 index 0000000..abf780e --- /dev/null +++ b/src/modules/configuration/customers/customers.repository.ts @@ -0,0 +1,500 @@ +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 { + customerContacts, + customers, + type CustomerContactRow, + type CustomerRow, + type NewCustomerRow, +} from '../../../database/customers-table'; +import type { + CreateCustomerInput, + Customer, + CustomerContact, + CustomerContactInput, + ListCustomersFilters, + UpdateCustomerContactInput, + UpdateCustomerInput, +} from './customer'; + +type QueryExecutor = Pick; + +@Injectable() +export class CustomersRepository { + constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {} + + async list( + filters: ListCustomersFilters, + ): Promise<{ data: Customer[]; total: number }> { + const where = this.buildListWhere(filters); + const totalRows = await this.db + .select({ total: count() }) + .from(customers) + .where(where); + const totalRow = totalRows[0]; + + let qb = this.db.select().from(customers).$dynamic(); + qb = this.extendListQuery(qb, filters); + const rows = await qb + .where(where) + .orderBy(asc(customers.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: ListCustomersFilters): T { + void filters; + return qb; + } + + async findById(id: string): Promise { + const rows: CustomerRow[] = await this.db + .select() + .from(customers) + .where(eq(customers.id, id)) + .limit(1); + const row = rows[0]; + if (!row) { + return null; + } + const contacts = await this.selectContacts(this.db, id); + return this.toDomain(row, contacts); + } + + async create(input: CreateCustomerInput): Promise { + const now = DateTime.fromUnixMs(Date.now()); + const status = input.status ?? Status.create(Status.DEFAULT); + try { + return await this.db.transaction(async (tx) => { + const inserted = await tx + .insert(customers) + .values(this.toInsertValues(input, status, now, input.userId)) + .returning(); + const row = inserted[0]; + await this.replaceContacts(tx, row.id, input.contacts ?? []); + const contacts = await this.selectContacts(tx, row.id); + return this.toDomain(row, contacts); + }); + } catch (error) { + this.rethrowConstraintViolation(error); + } + } + + async createMany(inputs: CreateCustomerInput[]): 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); + const inserted = await tx + .insert(customers) + .values(this.toInsertValues(input, status, now, input.userId)) + .returning(); + const row = inserted[0]; + await this.replaceContacts(tx, row.id, input.contacts ?? []); + } + }); + return inputs.length; + } catch (error) { + this.rethrowConstraintViolation(error); + } + } + + async update(id: string, input: UpdateCustomerInput): Promise { + const existing = await this.findById(id); + if (!existing) { + throw new NotFoundException('Customer not found'); + } + const now = DateTime.fromUnixMs(Date.now()); + try { + return await this.db.transaction(async (tx) => { + const values: Partial = { + code: input.code ?? existing.code, + name: input.name ?? existing.name, + phone: input.phone?.value ?? existing.phone.value, + address: input.address ?? existing.address, + latitude: + input.latitude !== undefined ? input.latitude : existing.latitude, + longitude: + input.longitude !== undefined + ? input.longitude + : existing.longitude, + nfcId: input.nfcId !== undefined ? input.nfcId : existing.nfcId, + updatedAt: now.value, + updatedBy: input.userId, + }; + const updated = await tx + .update(customers) + .set(values) + .where(eq(customers.id, id)) + .returning(); + const row = updated[0]; + if (!row) { + throw new NotFoundException('Customer not found'); + } + if (input.contacts !== undefined) { + await this.replaceContacts(tx, id, input.contacts); + } + const contacts = await this.selectContacts(tx, id); + return this.toDomain(row, contacts); + }); + } catch (error) { + this.rethrowConstraintViolation(error); + } + } + + async updateStatus( + id: string, + status: Status, + userId: string, + ): Promise { + const now = DateTime.fromUnixMs(Date.now()); + const updated = await this.db + .update(customers) + .set({ + status: status.value, + updatedAt: now.value, + updatedBy: userId, + }) + .where(eq(customers.id, id)) + .returning(); + const row = updated[0]; + if (!row) { + throw new NotFoundException('Customer not found'); + } + const contacts = await this.selectContacts(this.db, id); + return this.toDomain(row, contacts); + } + + 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(customers) + .set({ + status: status.value, + updatedAt: now.value, + updatedBy: userId, + }) + .where(inArray(customers.id, ids)) + .returning({ id: customers.id }); + return rows.length; + } + + async delete(id: string): Promise { + const deleted = await this.db + .delete(customers) + .where(eq(customers.id, id)) + .returning({ id: customers.id }); + if (deleted.length === 0) { + throw new NotFoundException('Customer not found'); + } + } + + async bulkDelete(ids: string[]): Promise { + if (ids.length === 0) { + return 0; + } + const deleted = await this.db + .delete(customers) + .where(inArray(customers.id, ids)) + .returning({ id: customers.id }); + return deleted.length; + } + + async addContact( + customerId: string, + input: CustomerContactInput, + userId: string, + ): Promise { + const existing = await this.findById(customerId); + if (!existing) { + throw new NotFoundException('Customer not found'); + } + const now = DateTime.fromUnixMs(Date.now()); + await this.db.insert(customerContacts).values({ + customerId, + name: input.name, + jobTitle: input.jobTitle ?? null, + phone: input.phone?.value ?? null, + mobilePhone: input.mobilePhone?.value ?? null, + notes: input.notes ?? null, + }); + await this.touchCustomer(customerId, userId, now); + const found = await this.findById(customerId); + if (!found) { + throw new NotFoundException('Customer not found'); + } + return found; + } + + async updateContact( + customerId: string, + contactId: string, + input: UpdateCustomerContactInput, + ): Promise { + const existing = await this.findById(customerId); + if (!existing) { + throw new NotFoundException('Customer not found'); + } + const current = existing.contacts.find((c) => c.id === contactId); + if (!current) { + throw new NotFoundException('Contact not found'); + } + const now = DateTime.fromUnixMs(Date.now()); + const updated = await this.db + .update(customerContacts) + .set({ + name: input.name ?? current.name, + jobTitle: + input.jobTitle !== undefined ? input.jobTitle : current.jobTitle, + phone: + input.phone !== undefined + ? (input.phone?.value ?? null) + : (current.phone?.value ?? null), + mobilePhone: + input.mobilePhone !== undefined + ? (input.mobilePhone?.value ?? null) + : (current.mobilePhone?.value ?? null), + notes: input.notes !== undefined ? input.notes : current.notes, + }) + .where( + and( + eq(customerContacts.id, contactId), + eq(customerContacts.customerId, customerId), + ), + ) + .returning({ id: customerContacts.id }); + if (updated.length === 0) { + throw new NotFoundException('Contact not found'); + } + await this.touchCustomer(customerId, input.userId, now); + const found = await this.findById(customerId); + if (!found) { + throw new NotFoundException('Customer not found'); + } + return found; + } + + async deleteContact(customerId: string, contactId: string): Promise { + const deleted = await this.db + .delete(customerContacts) + .where( + and( + eq(customerContacts.id, contactId), + eq(customerContacts.customerId, customerId), + ), + ) + .returning({ id: customerContacts.id }); + if (deleted.length === 0) { + throw new NotFoundException('Contact not found'); + } + } + + private async touchCustomer( + customerId: string, + userId: string, + now: DateTime, + ): Promise { + await this.db + .update(customers) + .set({ + updatedAt: now.value, + updatedBy: userId, + }) + .where(eq(customers.id, customerId)); + } + + private async selectContacts( + executor: QueryExecutor, + customerId: string, + ): Promise { + return executor + .select() + .from(customerContacts) + .where(eq(customerContacts.customerId, customerId)) + .orderBy(asc(customerContacts.name)); + } + + private async replaceContacts( + executor: QueryExecutor, + customerId: string, + contacts: readonly CustomerContactInput[], + ): Promise { + await executor + .delete(customerContacts) + .where(eq(customerContacts.customerId, customerId)); + if (contacts.length === 0) { + return; + } + await executor.insert(customerContacts).values( + contacts.map((contact) => ({ + customerId, + name: contact.name, + jobTitle: contact.jobTitle ?? null, + phone: contact.phone?.value ?? null, + mobilePhone: contact.mobilePhone?.value ?? null, + notes: contact.notes ?? null, + })), + ); + } + + private buildListWhere(filters: ListCustomersFilters): SQL | undefined { + const parts: SQL[] = []; + if (filters.code) { + parts.push(ilike(customers.code, `%${filters.code}%`)); + } + if (filters.name) { + parts.push(ilike(customers.name, `%${filters.name}%`)); + } + if (filters.phone) { + parts.push(ilike(customers.phone, `%${filters.phone}%`)); + } + if (filters.address) { + parts.push(ilike(customers.address, `%${filters.address}%`)); + } + if (filters.nfcId) { + parts.push(eq(customers.nfcId, filters.nfcId)); + } + if (filters.status) { + parts.push(eq(customers.status, filters.status)); + } + if (filters.search) { + const search = or( + ilike(customers.code, `%${filters.search}%`), + ilike(customers.name, `%${filters.search}%`), + ilike(customers.address, `%${filters.search}%`), + ); + if (search) { + parts.push(search); + } + } + if (parts.length === 0) { + return undefined; + } + return parts.length === 1 ? parts[0] : and(...parts); + } + + private toInsertValues( + input: CreateCustomerInput, + status: Status, + now: DateTime, + userId: string, + ) { + return { + code: input.code, + name: input.name, + phone: input.phone.value, + address: input.address, + latitude: input.latitude ?? null, + longitude: input.longitude ?? null, + nfcId: input.nfcId ?? null, + status: status.value, + createdAt: now.value, + updatedAt: now.value, + createdBy: userId, + updatedBy: userId, + }; + } + + private toDomain( + row: CustomerRow, + contactRows: CustomerContactRow[], + ): Customer { + return { + id: row.id, + code: row.code, + name: row.name, + phone: PhoneNumber.create(row.phone), + address: row.address, + latitude: row.latitude, + longitude: row.longitude, + nfcId: row.nfcId, + contacts: contactRows.map((contact) => this.toContactDomain(contact)), + status: Status.create(row.status), + createdAt: DateTime.fromUnixMs(row.createdAt), + updatedAt: DateTime.fromUnixMs(row.updatedAt), + createdBy: row.createdBy, + updatedBy: row.updatedBy, + }; + } + + private toContactDomain(row: CustomerContactRow): CustomerContact { + return { + id: row.id, + customerId: row.customerId, + name: row.name, + jobTitle: row.jobTitle, + phone: row.phone ? PhoneNumber.create(row.phone) : null, + mobilePhone: row.mobilePhone ? PhoneNumber.create(row.mobilePhone) : null, + notes: row.notes, + }; + } + + private rethrowConstraintViolation(error: unknown): never { + if (error instanceof NotFoundException) { + throw error; + } + const err = this.unwrapDbError(error); + if (err.code === '23505') { + const constraint = err.constraint ?? ''; + if (constraint.includes('nfc')) { + throw new ConflictException('Customer NFC ID already exists'); + } + throw new ConflictException('Customer code already exists'); + } + throw error; + } + + private unwrapDbError(error: unknown): { + code?: string; + constraint?: string; + } { + let current: unknown = error; + for (let i = 0; i < 5; i++) { + if (!current || typeof current !== 'object') { + break; + } + const obj = current as { + code?: string; + constraint?: string; + constraint_name?: string; + cause?: unknown; + }; + if (obj.code === '23505' || obj.code === '23503') { + return { + code: obj.code, + constraint: obj.constraint ?? obj.constraint_name, + }; + } + current = obj.cause; + } + return error as { code?: string; constraint?: string }; + } +} diff --git a/src/modules/configuration/customers/customers.service.spec.ts b/src/modules/configuration/customers/customers.service.spec.ts new file mode 100644 index 0000000..d7361bc --- /dev/null +++ b/src/modules/configuration/customers/customers.service.spec.ts @@ -0,0 +1,277 @@ +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 { Customer } from './customer'; +import { CustomersRepository } from './customers.repository'; +import { CustomersService } from './customers.service'; + +describe('CustomersService', () => { + let service: CustomersService; + let repository: jest.Mocked< + Pick< + CustomersRepository, + | 'list' + | 'findById' + | 'create' + | 'createMany' + | 'update' + | 'updateStatus' + | 'bulkUpdateStatus' + | 'delete' + | 'bulkDelete' + | 'addContact' + | 'updateContact' + | 'deleteContact' + > + >; + + const now = DateTime.fromUnixMs(1_700_000_000_000); + const sample: Customer = { + id: 'cu-1', + code: 'CUST_01', + name: 'Acme Corp', + phone: PhoneNumber.create('+6281234567890'), + address: 'Jl Sudirman No 1', + latitude: -6.2, + longitude: 106.8, + nfcId: 'NFC-001', + contacts: [ + { + id: 'ct-1', + customerId: 'cu-1', + name: 'Jean Luc', + jobTitle: 'Buyer', + phone: PhoneNumber.create('+6281234567891'), + mobilePhone: null, + notes: 'Primary', + }, + ], + status: Status.create('draft'), + createdAt: now, + updatedAt: now, + createdBy: 'user-1', + updatedBy: 'user-1', + }; + + const createInput = { + code: 'CUST_01', + name: 'Acme Corp', + phone: '+6281234567890', + address: 'Jl Sudirman No 1', + 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(), + addContact: jest.fn(), + updateContact: jest.fn(), + deleteContact: jest.fn(), + }; + + const moduleRef: TestingModule = await Test.createTestingModule({ + providers: [ + CustomersService, + { provide: CustomersRepository, useValue: repository }, + ], + }).compile(); + + service = moduleRef.get(CustomersService); + }); + + it('list maps visible fields without contacts', 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: 'cu-1', + code: 'CUST_01', + name: 'Acme Corp', + phone: '+6281234567890', + status: 'draft', + }); + expect(result.data[0]).not.toHaveProperty('contacts'); + expect(service.visibleFields).toContain('phone'); + }); + + it('findById throws when missing', async () => { + repository.findById.mockResolvedValue(null); + await expect(service.findById('missing')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('findById returns mapped item with contacts', async () => { + repository.findById.mockResolvedValue(sample); + const result = await service.findById('cu-1'); + expect(result.id).toBe('cu-1'); + expect(result.contacts).toHaveLength(1); + expect(result.contacts[0].name).toBe('Jean Luc'); + expect(result.contacts[0].phone).toBe('+6281234567891'); + }); + + it('create defaults status to draft and maps contacts', async () => { + repository.create.mockResolvedValue(sample); + await service.create({ + ...createInput, + contacts: [{ name: 'Jean Luc', phone: '+6281234567891' }], + }); + const arg = repository.create.mock.calls[0][0]; + expect(arg.status?.value).toBe('draft'); + expect(arg.phone.value).toBe('+6281234567890'); + expect(arg.contacts?.[0].name).toBe('Jean Luc'); + expect(arg.contacts?.[0].phone?.value).toBe('+6281234567891'); + }); + + 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 or code', async () => { + await expect( + service.create({ ...createInput, name: 'Acme1' }), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + service.create({ ...createInput, code: 'CUST 01' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('create rejects out of range coordinates', async () => { + await expect( + service.create({ ...createInput, latitude: 91 }), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + service.create({ ...createInput, longitude: 181 }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('update rejects status field', async () => { + await expect( + service.update('cu-1', { status: 'active', userId: 'user-1' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('update replaces contacts when contacts is sent', async () => { + repository.update.mockResolvedValue(sample); + await service.update('cu-1', { + contacts: [{ name: 'Ada Lovelace' }], + userId: 'user-1', + }); + expect(repository.update).toHaveBeenCalledWith( + 'cu-1', + expect.objectContaining({ + contacts: [expect.objectContaining({ name: 'Ada Lovelace' })], + }), + ); + }); + + it('update leaves contacts unchanged when omitted', async () => { + repository.update.mockResolvedValue(sample); + await service.update('cu-1', { name: 'Acme Corp', userId: 'user-1' }); + expect(repository.update).toHaveBeenCalledWith( + 'cu-1', + expect.objectContaining({ + name: 'Acme Corp', + contacts: undefined, + }), + ); + }); + + it('updateStatus updates via repository', async () => { + repository.updateStatus.mockResolvedValue(sample); + await service.updateStatus('cu-1', 'active', 'user-1'); + expect(repository.updateStatus).toHaveBeenCalledWith( + 'cu-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('cu-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('addContact and updateContact validate phones', async () => { + repository.addContact.mockResolvedValue(sample); + repository.updateContact.mockResolvedValue(sample); + await service.addContact('cu-1', { name: 'Ada Lovelace' }, 'user-1'); + expect(repository.addContact).toHaveBeenCalledWith( + 'cu-1', + expect.objectContaining({ name: 'Ada Lovelace' }), + 'user-1', + ); + await expect( + service.addContact('cu-1', { name: 'Ada', phone: '0812' }, 'user-1'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('deleteContact delegates', async () => { + repository.deleteContact.mockResolvedValue(undefined); + await service.deleteContact('cu-1', 'ct-1'); + expect(repository.deleteContact).toHaveBeenCalledWith('cu-1', 'ct-1'); + }); + + it('importCsv imports valid rows without contacts', async () => { + repository.createMany.mockResolvedValue(1); + const csv = + 'code,name,phone,address,status\n' + + 'CUST_01,Acme Corp,+6281234567890,Jl Sudirman No 1,draft'; + const result = await service.importCsv(csv, 'user-1'); + expect(result.imported).toBe(1); + expect(repository.createMany).toHaveBeenCalledTimes(1); + expect(repository.createMany.mock.calls[0][0][0].contacts).toEqual([]); + }); + + it('importCsv fails the batch on invalid phone', async () => { + const csv = + 'code,name,phone,address\n' + 'CUST_01,Acme Corp,081234,Jl Sudirman No 1'; + await expect(service.importCsv(csv, 'user-1')).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(repository.createMany).not.toHaveBeenCalled(); + }); + + it('importCsv rejects empty and headerless files', async () => { + await expect(service.importCsv('', 'user-1')).rejects.toBeInstanceOf( + BadRequestException, + ); + await expect( + service.importCsv('code,name\nCUST_01,Acme', 'user-1'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('importCsv rejects oversized files', async () => { + const huge = [ + 'code,name,phone,address', + ...Array.from( + { length: 501 }, + (_, i) => `C${i},Acme Corp,+6281234567890,Jl Sudirman`, + ), + ].join('\n'); + await expect(service.importCsv(huge, 'user-1')).rejects.toBeInstanceOf( + BadRequestException, + ); + }); +}); diff --git a/src/modules/configuration/customers/customers.service.ts b/src/modules/configuration/customers/customers.service.ts new file mode 100644 index 0000000..21853f9 --- /dev/null +++ b/src/modules/configuration/customers/customers.service.ts @@ -0,0 +1,512 @@ +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 { + CreateCustomerInput, + Customer, + CustomerContact, + CustomerContactInput, + UpdateCustomerContactInput, + UpdateCustomerInput, +} from './customer'; +import { + isValidContactJobTitle, + isValidContactName, + isValidContactNotes, + isValidCustomerAddress, + isValidCustomerCode, + isValidCustomerName, + isValidLatitude, + isValidLongitude, + isValidNfcId, + parseCsvRecord, +} from './customer-fields'; +import { CustomersRepository } from './customers.repository'; + +export type ListCustomersQuery = { + readonly code?: string; + readonly name?: string; + readonly phone?: string; + readonly address?: string; + readonly nfcId?: string; + readonly status?: string; + readonly search?: string; + readonly page?: number; + readonly limit?: number; + readonly offset?: number; +}; + +export type ContactBody = { + readonly name: string; + readonly jobTitle?: string | null; + readonly phone?: string | null; + readonly mobilePhone?: string | null; + readonly notes?: string | null; +}; + +const VISIBLE_FIELDS = [ + 'id', + 'code', + 'name', + 'phone', + 'address', + 'latitude', + 'longitude', + 'nfcId', + 'status', + 'createdAt', + 'updatedAt', + 'createdBy', + 'updatedBy', +] as const; + +const CSV_REQUIRED_HEADERS = ['code', 'name', 'phone', 'address'] as const; + +@Injectable() +export class CustomersService { + constructor(private readonly customersRepository: CustomersRepository) {} + + async list( + query: ListCustomersQuery, + ): Promise>> { + const page = toListPage(query); + const { data, total } = await this.customersRepository.list({ + code: query.code, + name: query.name, + phone: query.phone, + address: query.address, + nfcId: query.nfcId, + 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 customer = await this.customersRepository.findById(id); + if (!customer) { + throw new NotFoundException('Customer not found'); + } + return this.toDetail(customer); + } + + async create(input: { + code: string; + name: string; + phone: string; + address: string; + latitude?: number | null; + longitude?: number | null; + nfcId?: string | null; + status?: string; + contacts?: ContactBody[]; + userId: string; + }): Promise> { + const created = await this.customersRepository.create( + this.toCreateInput(input), + ); + return this.toDetail(created); + } + + async update( + id: string, + input: { + code?: string; + name?: string; + phone?: string; + address?: string; + latitude?: number | null; + longitude?: number | null; + nfcId?: string | null; + contacts?: ContactBody[]; + status?: unknown; + userId: string; + }, + ): Promise> { + if (input.status !== undefined) { + throw new BadRequestException('status cannot be updated via PATCH'); + } + const payload: UpdateCustomerInput = { + code: input.code !== undefined ? this.assertCode(input.code) : undefined, + name: input.name !== undefined ? this.assertName(input.name) : undefined, + phone: + input.phone !== undefined ? this.assertPhone(input.phone) : undefined, + address: + input.address !== undefined + ? this.assertAddress(input.address) + : undefined, + latitude: + input.latitude !== undefined + ? this.assertLatitude(input.latitude) + : undefined, + longitude: + input.longitude !== undefined + ? this.assertLongitude(input.longitude) + : undefined, + nfcId: + input.nfcId !== undefined ? this.assertNfcId(input.nfcId) : undefined, + contacts: + input.contacts !== undefined + ? input.contacts.map((contact) => this.assertContact(contact)) + : undefined, + userId: input.userId, + }; + const updated = await this.customersRepository.update(id, payload); + return this.toDetail(updated); + } + + async updateStatus( + id: string, + statusRaw: string, + userId: string, + ): Promise> { + const status = Status.create(statusRaw); + const updated = await this.customersRepository.updateStatus( + id, + status, + userId, + ); + return this.toDetail(updated); + } + + async bulkUpdateStatus( + ids: string[], + statusRaw: string, + userId: string, + ): Promise<{ updated: number }> { + const status = Status.create(statusRaw); + const updated = await this.customersRepository.bulkUpdateStatus( + ids, + status, + userId, + ); + return { updated }; + } + + async delete(id: string): Promise { + await this.customersRepository.delete(id); + } + + async bulkDelete(ids: string[]): Promise<{ deleted: number }> { + const deleted = await this.customersRepository.bulkDelete(ids); + return { deleted }; + } + + async addContact( + customerId: string, + body: ContactBody, + userId: string, + ): Promise> { + const updated = await this.customersRepository.addContact( + customerId, + this.assertContact(body), + userId, + ); + return this.toDetail(updated); + } + + async updateContact( + customerId: string, + contactId: string, + body: Partial & { userId: string }, + ): Promise> { + const payload: UpdateCustomerContactInput = { + name: + body.name !== undefined ? this.assertContactName(body.name) : undefined, + jobTitle: + body.jobTitle !== undefined + ? this.assertOptionalJobTitle(body.jobTitle) + : undefined, + phone: + body.phone !== undefined + ? this.assertOptionalPhone(body.phone) + : undefined, + mobilePhone: + body.mobilePhone !== undefined + ? this.assertOptionalPhone(body.mobilePhone) + : undefined, + notes: + body.notes !== undefined + ? this.assertOptionalNotes(body.notes) + : undefined, + userId: body.userId, + }; + const updated = await this.customersRepository.updateContact( + customerId, + contactId, + payload, + ); + return this.toDetail(updated); + } + + async deleteContact(customerId: string, contactId: string): Promise { + await this.customersRepository.deleteContact(customerId, contactId); + } + + 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: CreateCustomerInput[] = []; + for (let i = 1; i < filled.length; i++) { + const cols = parseCsvRecord(filled[i].line); + const rowNum = filled[i].lineNo; + try { + const statusRaw = idx('status') >= 0 ? cols[idx('status')] : ''; + const latitudeRaw = + idx('latitude') >= 0 ? cols[idx('latitude')] : undefined; + const longitudeRaw = + idx('longitude') >= 0 ? cols[idx('longitude')] : undefined; + const nfcRaw = idx('nfcid') >= 0 ? cols[idx('nfcid')] : undefined; + rows.push( + this.toCreateInput({ + code: cols[idx('code')] ?? '', + name: cols[idx('name')] ?? '', + phone: cols[idx('phone')] ?? '', + address: cols[idx('address')] ?? '', + latitude: + latitudeRaw === undefined || latitudeRaw === '' + ? undefined + : Number(latitudeRaw), + longitude: + longitudeRaw === undefined || longitudeRaw === '' + ? undefined + : Number(longitudeRaw), + nfcId: nfcRaw || undefined, + status: statusRaw || undefined, + userId, + }), + ); + } catch (error) { + const reason = + error instanceof BadRequestException ? error.message : 'invalid data'; + errors.push(`row ${rowNum}: ${reason}`); + } + } + + if (errors.length > 0) { + throw new BadRequestException({ + message: 'CSV validation failed', + errors, + }); + } + + await this.customersRepository.createMany(rows); + return { imported: rows.length }; + } + + toListItem(customer: Customer) { + return { + id: customer.id, + code: customer.code, + name: customer.name, + phone: customer.phone.value, + address: customer.address, + latitude: customer.latitude, + longitude: customer.longitude, + nfcId: customer.nfcId, + status: customer.status.value, + createdAt: customer.createdAt.value, + updatedAt: customer.updatedAt.value, + createdBy: customer.createdBy, + updatedBy: customer.updatedBy, + }; + } + + toDetail(customer: Customer) { + return { + ...this.toListItem(customer), + contacts: customer.contacts.map((contact) => this.toContactItem(contact)), + }; + } + + get visibleFields(): readonly string[] { + return VISIBLE_FIELDS; + } + + private toContactItem(contact: CustomerContact) { + return { + id: contact.id, + customerId: contact.customerId, + name: contact.name, + jobTitle: contact.jobTitle, + phone: contact.phone?.value ?? null, + mobilePhone: contact.mobilePhone?.value ?? null, + notes: contact.notes, + }; + } + + private toCreateInput(input: { + code: string; + name: string; + phone: string; + address: string; + latitude?: number | null; + longitude?: number | null; + nfcId?: string | null; + status?: string; + contacts?: ContactBody[]; + userId: string; + }): CreateCustomerInput { + return { + code: this.assertCode(input.code), + name: this.assertName(input.name), + phone: this.assertPhone(input.phone), + address: this.assertAddress(input.address), + latitude: this.assertLatitude(input.latitude ?? null), + longitude: this.assertLongitude(input.longitude ?? null), + nfcId: this.assertNfcId(input.nfcId ?? null), + contacts: (input.contacts ?? []).map((contact) => + this.assertContact(contact), + ), + status: input.status + ? Status.create(input.status) + : Status.create(Status.DEFAULT), + userId: input.userId, + }; + } + + private assertContact(raw: ContactBody): CustomerContactInput { + return { + name: this.assertContactName(raw.name), + jobTitle: this.assertOptionalJobTitle(raw.jobTitle ?? null), + phone: this.assertOptionalPhone(raw.phone ?? null), + mobilePhone: this.assertOptionalPhone(raw.mobilePhone ?? null), + notes: this.assertOptionalNotes(raw.notes ?? null), + }; + } + + private assertName(raw: string): string { + const name = raw.trim(); + if (!isValidCustomerName(name)) { + throw new BadRequestException('Invalid customer name'); + } + return name; + } + + private assertCode(raw: string): string { + const code = raw.trim(); + if (!isValidCustomerCode(code)) { + throw new BadRequestException('Invalid customer code'); + } + return code; + } + + private assertAddress(raw: string): string { + const address = raw.trim(); + if (!isValidCustomerAddress(address)) { + throw new BadRequestException('Invalid customer address'); + } + return address; + } + + private assertPhone(raw: string): PhoneNumber { + try { + return PhoneNumber.create(raw); + } catch (error) { + if (error instanceof InvalidPhoneNumberError) { + throw new BadRequestException('Invalid phone number'); + } + throw error; + } + } + + private assertOptionalPhone(raw: string | null): PhoneNumber | null { + if (raw === null || raw.trim() === '') { + return null; + } + return this.assertPhone(raw); + } + + private assertLatitude(raw: number | null): number | null { + if (raw === null) { + return null; + } + if (!isValidLatitude(raw)) { + throw new BadRequestException('Invalid latitude'); + } + return raw; + } + + private assertLongitude(raw: number | null): number | null { + if (raw === null) { + return null; + } + if (!isValidLongitude(raw)) { + throw new BadRequestException('Invalid longitude'); + } + return raw; + } + + private assertNfcId(raw: string | null): string | null { + if (raw === null || raw.trim() === '') { + return null; + } + const value = raw.trim(); + if (!isValidNfcId(value)) { + throw new BadRequestException('Invalid NFC ID'); + } + return value; + } + + private assertContactName(raw: string): string { + const name = raw.trim(); + if (!isValidContactName(name)) { + throw new BadRequestException('Invalid contact name'); + } + return name; + } + + private assertOptionalJobTitle(raw: string | null): string | null { + if (raw === null || raw.trim() === '') { + return null; + } + const value = raw.trim(); + if (!isValidContactJobTitle(value)) { + throw new BadRequestException('Invalid contact job title'); + } + return value; + } + + private assertOptionalNotes(raw: string | null): string | null { + if (raw === null) { + return null; + } + if (!isValidContactNotes(raw)) { + throw new BadRequestException('Invalid contact notes'); + } + return raw; + } +} diff --git a/src/modules/configuration/customers/dto/customer.dto.ts b/src/modules/configuration/customers/dto/customer.dto.ts new file mode 100644 index 0000000..ca3875c --- /dev/null +++ b/src/modules/configuration/customers/dto/customer.dto.ts @@ -0,0 +1,355 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayNotEmpty, + IsArray, + IsIn, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + IsUUID, + Matches, + Max, + MaxLength, + Min, + ValidateNested, +} from 'class-validator'; +import { PaginationQueryDto } from '../../../../common/http/response'; +import { CORE_STATUSES } from '../../../../common/value-objects/status/status'; +import { + CONTACT_JOB_TITLE_MAX_LENGTH, + CONTACT_NAME_MAX_LENGTH, + CONTACT_NOTES_MAX_LENGTH, + CUSTOMER_ADDRESS_MAX_LENGTH, + CUSTOMER_CODE_MAX_LENGTH, + CUSTOMER_CODE_PATTERN, + CUSTOMER_NAME_MAX_LENGTH, + CUSTOMER_NAME_PATTERN, + CUSTOMER_NFC_ID_MAX_LENGTH, +} from '../customer-fields'; + +export class CreateCustomerContactDto { + @ApiProperty({ example: 'Jean Luc', maxLength: CONTACT_NAME_MAX_LENGTH }) + @IsString() + @IsNotEmpty() + @MaxLength(CONTACT_NAME_MAX_LENGTH) + name!: string; + + @ApiPropertyOptional({ example: 'Purchasing Manager' }) + @IsOptional() + @IsString() + @MaxLength(CONTACT_JOB_TITLE_MAX_LENGTH) + jobTitle?: string; + + @ApiPropertyOptional({ example: '+6281234567890' }) + @IsOptional() + @IsString() + @IsNotEmpty() + phone?: string; + + @ApiPropertyOptional({ example: '+6281234567891' }) + @IsOptional() + @IsString() + @IsNotEmpty() + mobilePhone?: string; + + @ApiPropertyOptional({ example: 'Call after 9am' }) + @IsOptional() + @IsString() + @MaxLength(CONTACT_NOTES_MAX_LENGTH) + notes?: string; +} + +export class UpdateCustomerContactDto { + @ApiPropertyOptional({ example: 'Jean Luc' }) + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(CONTACT_NAME_MAX_LENGTH) + name?: string; + + @ApiPropertyOptional({ example: 'Purchasing Manager', nullable: true }) + @IsOptional() + @IsString() + @MaxLength(CONTACT_JOB_TITLE_MAX_LENGTH) + jobTitle?: string | null; + + @ApiPropertyOptional({ example: '+6281234567890', nullable: true }) + @IsOptional() + @IsString() + phone?: string | null; + + @ApiPropertyOptional({ example: '+6281234567891', nullable: true }) + @IsOptional() + @IsString() + mobilePhone?: string | null; + + @ApiPropertyOptional({ example: 'Call after 9am', nullable: true }) + @IsOptional() + @IsString() + @MaxLength(CONTACT_NOTES_MAX_LENGTH) + notes?: string | null; +} + +export class CustomerContactDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ format: 'uuid' }) + customerId!: string; + + @ApiProperty() + name!: string; + + @ApiPropertyOptional({ nullable: true }) + jobTitle!: string | null; + + @ApiPropertyOptional({ nullable: true }) + phone!: string | null; + + @ApiPropertyOptional({ nullable: true }) + mobilePhone!: string | null; + + @ApiPropertyOptional({ nullable: true }) + notes!: string | null; +} + +export class CreateCustomerDto { + @ApiProperty({ example: 'CUST_01', maxLength: CUSTOMER_CODE_MAX_LENGTH }) + @IsString() + @IsNotEmpty() + @MaxLength(CUSTOMER_CODE_MAX_LENGTH) + @Matches(CUSTOMER_CODE_PATTERN, { + message: 'code must contain only letters, numbers, and underscores', + }) + code!: string; + + @ApiProperty({ example: 'Acme Corp', maxLength: CUSTOMER_NAME_MAX_LENGTH }) + @IsString() + @IsNotEmpty() + @MaxLength(CUSTOMER_NAME_MAX_LENGTH) + @Matches(CUSTOMER_NAME_PATTERN, { + message: 'name must contain only letters and spaces', + }) + name!: string; + + @ApiProperty({ example: '+6281234567890' }) + @IsString() + @IsNotEmpty() + phone!: string; + + @ApiProperty({ example: 'Jl Sudirman No 1' }) + @IsString() + @IsNotEmpty() + @MaxLength(CUSTOMER_ADDRESS_MAX_LENGTH) + address!: string; + + @ApiPropertyOptional({ example: -6.2 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(-90) + @Max(90) + latitude?: number; + + @ApiPropertyOptional({ example: 106.8 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(-180) + @Max(180) + longitude?: number; + + @ApiPropertyOptional({ example: 'NFC-001' }) + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(CUSTOMER_NFC_ID_MAX_LENGTH) + nfcId?: string; + + @ApiPropertyOptional({ enum: CORE_STATUSES }) + @IsOptional() + @IsIn([...CORE_STATUSES]) + status?: string; + + @ApiPropertyOptional({ type: [CreateCustomerContactDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateCustomerContactDto) + contacts?: CreateCustomerContactDto[]; +} + +export class UpdateCustomerDto { + @ApiPropertyOptional({ example: 'CUST_01' }) + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(CUSTOMER_CODE_MAX_LENGTH) + @Matches(CUSTOMER_CODE_PATTERN, { + message: 'code must contain only letters, numbers, and underscores', + }) + code?: string; + + @ApiPropertyOptional({ example: 'Acme Corp' }) + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(CUSTOMER_NAME_MAX_LENGTH) + @Matches(CUSTOMER_NAME_PATTERN, { + message: 'name must contain only letters and spaces', + }) + name?: string; + + @ApiPropertyOptional({ example: '+6281234567890' }) + @IsOptional() + @IsString() + @IsNotEmpty() + phone?: string; + + @ApiPropertyOptional({ example: 'Jl Sudirman No 1' }) + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(CUSTOMER_ADDRESS_MAX_LENGTH) + address?: string; + + @ApiPropertyOptional({ example: -6.2, nullable: true }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(-90) + @Max(90) + latitude?: number | null; + + @ApiPropertyOptional({ example: 106.8, nullable: true }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(-180) + @Max(180) + longitude?: number | null; + + @ApiPropertyOptional({ example: 'NFC-001', nullable: true }) + @IsOptional() + @IsString() + @MaxLength(CUSTOMER_NFC_ID_MAX_LENGTH) + nfcId?: string | null; + + @ApiPropertyOptional({ type: [CreateCustomerContactDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateCustomerContactDto) + contacts?: CreateCustomerContactDto[]; +} + +export class UpdateCustomerStatusDto { + @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 ListCustomersQueryDto extends PaginationQueryDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + code?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + name?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + phone?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + address?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + nfcId?: string; + + @ApiPropertyOptional({ enum: CORE_STATUSES }) + @IsOptional() + @IsIn([...CORE_STATUSES]) + status?: string; + + @ApiPropertyOptional({ + description: 'Case-insensitive match on code, name, or address', + }) + @IsOptional() + @IsString() + search?: string; +} + +export class CustomerDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty() + code!: string; + + @ApiProperty() + name!: string; + + @ApiProperty({ example: '+6281234567890' }) + phone!: string; + + @ApiProperty() + address!: string; + + @ApiPropertyOptional({ nullable: true }) + latitude!: number | null; + + @ApiPropertyOptional({ nullable: true }) + longitude!: number | null; + + @ApiPropertyOptional({ nullable: true }) + nfcId!: string | null; + + @ApiPropertyOptional({ type: [CustomerContactDto] }) + contacts?: CustomerContactDto[]; + + @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/test/customers.e2e-spec.ts b/test/customers.e2e-spec.ts new file mode 100644 index 0000000..8fbb4dd --- /dev/null +++ b/test/customers.e2e-spec.ts @@ -0,0 +1,276 @@ +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { eq } from 'drizzle-orm'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { AppModule } from '../src/app.module'; +import { configureApp } from '../src/common/configure-app'; +import { DRIZZLE, type DrizzleDB } from '../src/database/database.module'; +import { + privilegeDetails, + privilegeKeys, + privileges, + users, +} from '../src/database/schema'; +import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action'; + +describe('Customers (e2e)', () => { + let app: INestApplication; + let db: DrizzleDB; + + const password = 'password123'; + const adminUsername = `cu_admin_${Date.now()}`; + const otherUsername = `cu_other_${Date.now()}`; + + let adminAccessToken: string; + let adminUserId: string; + let otherAccessToken: string; + + const payload = { + code: `CUST_${Date.now().toString().slice(-6)}`, + name: 'Acme Corp', + phone: '+6281234567890', + address: 'Jl Sudirman No 1', + }; + + 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: 'Customer Admin', + code: `CU_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 customers list without permission', async () => { + await request(app.getHttpServer()) + .get('/customers') + .set('Authorization', `Bearer ${otherAccessToken}`) + .expect(403); + }); + + it('rejects unauthenticated access', async () => { + await request(app.getHttpServer()).get('/customers').expect(401); + }); + + it('CRUD customers with contacts, uniqueness, nested routes, and bulk', async () => { + const created = await request(app.getHttpServer()) + .post('/customers') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ + ...payload, + contacts: [ + { + name: 'Jean Luc', + jobTitle: 'Buyer', + phone: '+6281234567891', + }, + ], + }) + .expect(201); + + expect(created.body).toMatchObject({ + code: payload.code, + name: 'Acme Corp', + phone: '+6281234567890', + status: 'draft', + createdBy: adminUserId, + latitude: null, + nfcId: null, + }); + expect( + (created.body as { contacts: Array<{ name: string }> }).contacts, + ).toEqual([expect.objectContaining({ name: 'Jean Luc' })]); + const id = (created.body as { id: string }).id; + + await request(app.getHttpServer()) + .post('/customers') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ ...payload, code: 'BAD 01', name: 'Other Corp' }) + .expect(400); + + await request(app.getHttpServer()) + .post('/customers') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ ...payload, code: 'PHN_01', phone: '081234567890' }) + .expect(400); + + await request(app.getHttpServer()) + .post('/customers') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ ...payload, code: payload.code, name: 'Other Corp' }) + .expect(409); + + const detail = await request(app.getHttpServer()) + .get(`/customers/${id}`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .expect(200); + expect((detail.body as { contacts: unknown[] }).contacts.length).toBe(1); + + await request(app.getHttpServer()) + .patch(`/customers/${id}`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ + name: 'Acme International', + latitude: -6.2, + longitude: 106.8, + nfcId: 'NFC-001', + contacts: [{ name: 'Ada Lovelace', jobTitle: 'Director' }], + }) + .expect(200); + + const replaced = await request(app.getHttpServer()) + .get(`/customers/${id}`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .expect(200); + expect( + (replaced.body as { contacts: Array<{ name: string }> }).contacts, + ).toEqual([expect.objectContaining({ name: 'Ada Lovelace' })]); + + await request(app.getHttpServer()) + .patch(`/customers/${id}`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ status: 'active' }) + .expect(400); + + await request(app.getHttpServer()) + .patch(`/customers/${id}/status`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ status: 'active' }) + .expect(200); + + const added = await request(app.getHttpServer()) + .post(`/customers/${id}/contacts`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ name: 'Grace Hopper', mobilePhone: '+6281234567892' }) + .expect(201); + const contacts = ( + added.body as { contacts: Array<{ id: string; name: string }> } + ).contacts; + expect(contacts.map((c) => c.name)).toEqual( + expect.arrayContaining(['Ada Lovelace', 'Grace Hopper']), + ); + const graceId = contacts.find((c) => c.name === 'Grace Hopper')!.id; + + await request(app.getHttpServer()) + .patch(`/customers/${id}/contacts/${graceId}`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ jobTitle: 'Engineer' }) + .expect(200); + + await request(app.getHttpServer()) + .delete(`/customers/${id}/contacts/${graceId}`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .expect(204); + + const list = await request(app.getHttpServer()) + .get('/customers?search=Acme') + .set('Authorization', `Bearer ${adminAccessToken}`) + .expect(200); + expect( + (list.body as { data: unknown[] }).data.length, + ).toBeGreaterThanOrEqual(1); + expect( + (list.body as { data: Array<{ contacts?: unknown }> }).data[0], + ).not.toHaveProperty('contacts'); + expect((list.body as { meta?: unknown }).meta).toBeDefined(); + + const extra = await request(app.getHttpServer()) + .post('/customers') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ + ...payload, + code: `OTH_${Date.now().toString().slice(-6)}`, + name: 'Beta Corp', + }) + .expect(201); + + await request(app.getHttpServer()) + .post('/customers/bulk-status') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ ids: [(extra.body as { id: string }).id], status: 'archived' }) + .expect(200); + + await request(app.getHttpServer()) + .post('/customers/bulk-delete') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ ids: [(extra.body as { id: string }).id] }) + .expect(200); + + await request(app.getHttpServer()) + .delete(`/customers/${id}`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .expect(204); + }); + + it('imports customers from CSV', async () => { + const suffix = Date.now().toString().slice(-6); + const csv = + 'code,name,phone,address,status\n' + + `IMP_${suffix},Imported Corp,+6281234567890,Jl Imported No 1,draft\n`; + const res = await request(app.getHttpServer()) + .post('/customers/import') + .set('Authorization', `Bearer ${adminAccessToken}`) + .attach('file', Buffer.from(csv, 'utf8'), 'customers.csv') + .expect(201); + + expect(res.body as { imported: number }).toMatchObject({ imported: 1 }); + }); +});