Add divisions management module with database schema and validation
- Introduced `DivisionsModule` to manage organizational divisions, including read and write controllers. - Created database migrations for the `divisions` table and related constraints. - Implemented validation for division name and code with corresponding utility functions. - Added service and repository layers for handling division data operations. - Developed unit tests for the divisions service, repository, and controllers to ensure functionality and correctness. - Updated application module to include the new `ConfigurationModule` for better organization.
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||
import { divisions, type DivisionRow } from '../../../database/schema';
|
||||
import type {
|
||||
CreateDivisionInput,
|
||||
Division,
|
||||
ListDivisionsFilters,
|
||||
UpdateDivisionInput,
|
||||
} from './division';
|
||||
|
||||
@Injectable()
|
||||
export class DivisionsRepository {
|
||||
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||
|
||||
async list(
|
||||
filters: ListDivisionsFilters,
|
||||
): Promise<{ data: Division[]; total: number }> {
|
||||
const where = this.buildListWhere(filters);
|
||||
const totalRows = await this.db
|
||||
.select({ total: count() })
|
||||
.from(divisions)
|
||||
.where(where);
|
||||
const totalRow = totalRows[0];
|
||||
|
||||
let qb = this.db.select().from(divisions).$dynamic();
|
||||
qb = this.extendListQuery(qb, filters);
|
||||
const rows = await qb
|
||||
.where(where)
|
||||
.orderBy(asc(divisions.code))
|
||||
.limit(filters.limit)
|
||||
.offset(filters.offset);
|
||||
|
||||
return {
|
||||
data: rows.map((row) => this.toDomain(row)),
|
||||
total: Number(totalRow?.total ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for modules to add joins/extra predicates without forking list.
|
||||
*/
|
||||
extendListQuery<T>(qb: T, filters: ListDivisionsFilters): T {
|
||||
void filters;
|
||||
return qb;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Division | null> {
|
||||
const rows: DivisionRow[] = await this.db
|
||||
.select()
|
||||
.from(divisions)
|
||||
.where(eq(divisions.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
|
||||
async findByCode(code: string): Promise<Division | null> {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(divisions)
|
||||
.where(eq(divisions.code, code))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
|
||||
async create(input: CreateDivisionInput): Promise<Division> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const status = input.status ?? Status.create(Status.DEFAULT);
|
||||
try {
|
||||
const inserted = await this.db
|
||||
.insert(divisions)
|
||||
.values({
|
||||
name: input.name,
|
||||
code: input.code,
|
||||
status: status.value,
|
||||
createdAt: now.value,
|
||||
updatedAt: now.value,
|
||||
createdBy: input.userId,
|
||||
updatedBy: input.userId,
|
||||
})
|
||||
.returning();
|
||||
const row = inserted[0];
|
||||
return this.toDomain(row);
|
||||
} catch (error) {
|
||||
this.rethrowUniqueViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createMany(inputs: CreateDivisionInput[]): Promise<number> {
|
||||
if (inputs.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
try {
|
||||
await this.db.transaction(async (tx) => {
|
||||
for (const input of inputs) {
|
||||
const status = input.status ?? Status.create(Status.DEFAULT);
|
||||
await tx.insert(divisions).values({
|
||||
name: input.name,
|
||||
code: input.code,
|
||||
status: status.value,
|
||||
createdAt: now.value,
|
||||
updatedAt: now.value,
|
||||
createdBy: input.userId,
|
||||
updatedBy: input.userId,
|
||||
});
|
||||
}
|
||||
});
|
||||
return inputs.length;
|
||||
} catch (error) {
|
||||
this.rethrowUniqueViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, input: UpdateDivisionInput): Promise<Division> {
|
||||
const existing = await this.findById(id);
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Division not found');
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
try {
|
||||
const updated = await this.db
|
||||
.update(divisions)
|
||||
.set({
|
||||
name: input.name ?? existing.name,
|
||||
code: input.code ?? existing.code,
|
||||
updatedAt: now.value,
|
||||
updatedBy: input.userId,
|
||||
})
|
||||
.where(eq(divisions.id, id))
|
||||
.returning();
|
||||
const row = updated[0];
|
||||
return this.toDomain(row);
|
||||
} catch (error) {
|
||||
this.rethrowUniqueViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
status: Status,
|
||||
userId: string,
|
||||
): Promise<Division> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const updated = await this.db
|
||||
.update(divisions)
|
||||
.set({
|
||||
status: status.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(eq(divisions.id, id))
|
||||
.returning();
|
||||
const row = updated[0];
|
||||
if (!row) {
|
||||
throw new NotFoundException('Division not found');
|
||||
}
|
||||
return this.toDomain(row);
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
ids: string[],
|
||||
status: Status,
|
||||
userId: string,
|
||||
): Promise<number> {
|
||||
if (ids.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const rows = await this.db
|
||||
.update(divisions)
|
||||
.set({
|
||||
status: status.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(inArray(divisions.id, ids))
|
||||
.returning({ id: divisions.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const deleted = await this.db
|
||||
.delete(divisions)
|
||||
.where(eq(divisions.id, id))
|
||||
.returning({ id: divisions.id });
|
||||
if (deleted.length === 0) {
|
||||
throw new NotFoundException('Division not found');
|
||||
}
|
||||
}
|
||||
|
||||
async bulkDelete(ids: string[]): Promise<number> {
|
||||
if (ids.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const deleted = await this.db
|
||||
.delete(divisions)
|
||||
.where(inArray(divisions.id, ids))
|
||||
.returning({ id: divisions.id });
|
||||
return deleted.length;
|
||||
}
|
||||
|
||||
private buildListWhere(filters: ListDivisionsFilters): SQL | undefined {
|
||||
const parts: SQL[] = [];
|
||||
if (filters.name) {
|
||||
parts.push(ilike(divisions.name, `%${filters.name}%`));
|
||||
}
|
||||
if (filters.code) {
|
||||
parts.push(ilike(divisions.code, `%${filters.code}%`));
|
||||
}
|
||||
if (filters.status) {
|
||||
parts.push(eq(divisions.status, filters.status));
|
||||
}
|
||||
if (filters.search) {
|
||||
const search = or(
|
||||
ilike(divisions.name, `%${filters.search}%`),
|
||||
ilike(divisions.code, `%${filters.search}%`),
|
||||
);
|
||||
if (search) {
|
||||
parts.push(search);
|
||||
}
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return parts.length === 1 ? parts[0] : and(...parts);
|
||||
}
|
||||
|
||||
private toDomain(row: DivisionRow): Division {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
code: row.code,
|
||||
status: Status.create(row.status),
|
||||
createdAt: DateTime.fromUnixMs(row.createdAt),
|
||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
private rethrowUniqueViolation(error: unknown): never {
|
||||
const err = error as { code?: string };
|
||||
if (err.code === '23505') {
|
||||
throw new ConflictException('Division code already exists');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user