Add customers management module with database schema and validation
- Introduced `CustomersModule` to manage customer data, including read and write controllers. - Created database migrations for the `customers` and `customer_contacts` tables, including constraints and unique indexes. - Implemented validation for customer fields such as name, code, and address with corresponding utility functions. - Developed service and repository layers for handling customer data operations. - Added unit tests for the customers service, repository, and controllers to ensure functionality and correctness. - Updated application module to include the new `CustomersModule` for better organization.
This commit is contained in:
@@ -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);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,13 @@
|
|||||||
"when": 1787549883658,
|
"when": 1787549883658,
|
||||||
"tag": "0005_past_vengeance",
|
"tag": "0005_past_vengeance",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787554000000,
|
||||||
|
"tag": "0006_customers",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
@@ -143,3 +143,11 @@ export type DivisionRow = typeof divisions.$inferSelect;
|
|||||||
export type NewDivisionRow = typeof divisions.$inferInsert;
|
export type NewDivisionRow = typeof divisions.$inferInsert;
|
||||||
|
|
||||||
export { branches, type BranchRow, type NewBranchRow } from './branches-table';
|
export { branches, type BranchRow, type NewBranchRow } from './branches-table';
|
||||||
|
export {
|
||||||
|
customerContacts,
|
||||||
|
customers,
|
||||||
|
type CustomerContactRow,
|
||||||
|
type CustomerRow,
|
||||||
|
type NewCustomerContactRow,
|
||||||
|
type NewCustomerRow,
|
||||||
|
} from './customers-table';
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { BranchesModule } from './branches/branches.module';
|
import { BranchesModule } from './branches/branches.module';
|
||||||
|
import { CustomersModule } from './customers/customers.module';
|
||||||
import { DivisionsModule } from './divisions/divisions.module';
|
import { DivisionsModule } from './divisions/divisions.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DivisionsModule, BranchesModule],
|
imports: [DivisionsModule, BranchesModule, CustomersModule],
|
||||||
exports: [DivisionsModule, BranchesModule],
|
exports: [DivisionsModule, BranchesModule, CustomersModule],
|
||||||
})
|
})
|
||||||
export class ConfigurationModule {}
|
export class ConfigurationModule {}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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')
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<PaginationResponse<CustomerDto>> {
|
||||||
|
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<CustomerDto> {
|
||||||
|
return this.customersService.findById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PaginationMetaDto;
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<CustomerDto> {
|
||||||
|
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<CustomerDto> {
|
||||||
|
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<CustomerDto> {
|
||||||
|
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<void> {
|
||||||
|
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<CustomerDto> {
|
||||||
|
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<CustomerDto> {
|
||||||
|
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<void> {
|
||||||
|
await this.customersService.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<DrizzleDB, 'select' | 'insert' | 'delete'>;
|
||||||
|
|
||||||
|
@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<T>(qb: T, filters: ListCustomersFilters): T {
|
||||||
|
void filters;
|
||||||
|
return qb;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<Customer | null> {
|
||||||
|
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<Customer> {
|
||||||
|
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<number> {
|
||||||
|
if (inputs.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const now = DateTime.fromUnixMs(Date.now());
|
||||||
|
try {
|
||||||
|
await this.db.transaction(async (tx) => {
|
||||||
|
for (const input of inputs) {
|
||||||
|
const status = input.status ?? Status.create(Status.DEFAULT);
|
||||||
|
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<Customer> {
|
||||||
|
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<NewCustomerRow> = {
|
||||||
|
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<Customer> {
|
||||||
|
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<number> {
|
||||||
|
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<void> {
|
||||||
|
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<number> {
|
||||||
|
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<Customer> {
|
||||||
|
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<Customer> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
await this.db
|
||||||
|
.update(customers)
|
||||||
|
.set({
|
||||||
|
updatedAt: now.value,
|
||||||
|
updatedBy: userId,
|
||||||
|
})
|
||||||
|
.where(eq(customers.id, customerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async selectContacts(
|
||||||
|
executor: QueryExecutor,
|
||||||
|
customerId: string,
|
||||||
|
): Promise<CustomerContactRow[]> {
|
||||||
|
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<void> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<PaginationResponse<ReturnType<CustomersService['toListItem']>>> {
|
||||||
|
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<ReturnType<CustomersService['toDetail']>> {
|
||||||
|
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<ReturnType<CustomersService['toDetail']>> {
|
||||||
|
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<ReturnType<CustomersService['toDetail']>> {
|
||||||
|
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<ReturnType<CustomersService['toDetail']>> {
|
||||||
|
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<void> {
|
||||||
|
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<ReturnType<CustomersService['toDetail']>> {
|
||||||
|
const updated = await this.customersRepository.addContact(
|
||||||
|
customerId,
|
||||||
|
this.assertContact(body),
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
return this.toDetail(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateContact(
|
||||||
|
customerId: string,
|
||||||
|
contactId: string,
|
||||||
|
body: Partial<ContactBody> & { userId: string },
|
||||||
|
): Promise<ReturnType<CustomersService['toDetail']>> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<App>;
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user