Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
489
src/lib/services/clients.service.ts
Normal file
489
src/lib/services/clients.service.ts
Normal file
@@ -0,0 +1,489 @@
|
||||
import { and, eq, ilike, inArray, or } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import {
|
||||
clients,
|
||||
clientContacts,
|
||||
clientRelationships,
|
||||
clientTags,
|
||||
} from '@/lib/db/schema/clients';
|
||||
import { tags } from '@/lib/db/schema/system';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { NotFoundError } from '@/lib/errors';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
import { buildListQuery } from '@/lib/db/query-builder';
|
||||
import { diffEntity } from '@/lib/entity-diff';
|
||||
import { softDelete, restore, withTransaction } from '@/lib/db/utils';
|
||||
import type {
|
||||
CreateClientInput,
|
||||
UpdateClientInput,
|
||||
ListClientsInput,
|
||||
} from '@/lib/validators/clients';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface AuditMeta {
|
||||
userId: string;
|
||||
portId: string;
|
||||
ipAddress: string;
|
||||
userAgent: string;
|
||||
}
|
||||
|
||||
// ─── List ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listClients(portId: string, query: ListClientsInput) {
|
||||
const { page, limit, sort, order, search, includeArchived, source, nationality, isProxy, tagIds } = query;
|
||||
|
||||
const filters = [];
|
||||
|
||||
if (source) {
|
||||
filters.push(eq(clients.source, source));
|
||||
}
|
||||
if (nationality) {
|
||||
filters.push(ilike(clients.nationality, `%${nationality}%`));
|
||||
}
|
||||
if (isProxy !== undefined) {
|
||||
filters.push(eq(clients.isProxy, isProxy));
|
||||
}
|
||||
if (tagIds && tagIds.length > 0) {
|
||||
const clientsWithTags = await db
|
||||
.selectDistinct({ clientId: clientTags.clientId })
|
||||
.from(clientTags)
|
||||
.where(inArray(clientTags.tagId, tagIds));
|
||||
const matchingIds = clientsWithTags.map((r) => r.clientId);
|
||||
if (matchingIds.length > 0) {
|
||||
filters.push(inArray(clients.id, matchingIds));
|
||||
} else {
|
||||
// No clients match these tags — return empty
|
||||
return { data: [], total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
let sortColumn: typeof clients.fullName | typeof clients.createdAt | typeof clients.updatedAt =
|
||||
clients.updatedAt;
|
||||
if (sort === 'fullName') sortColumn = clients.fullName;
|
||||
else if (sort === 'createdAt') sortColumn = clients.createdAt;
|
||||
|
||||
const result = await buildListQuery({
|
||||
table: clients,
|
||||
portIdColumn: clients.portId,
|
||||
portId,
|
||||
idColumn: clients.id,
|
||||
updatedAtColumn: clients.updatedAt,
|
||||
searchColumns: [clients.fullName, clients.companyName],
|
||||
searchTerm: search,
|
||||
filters,
|
||||
sort: sort ? { column: sortColumn, direction: order } : undefined,
|
||||
page,
|
||||
pageSize: limit,
|
||||
includeArchived,
|
||||
archivedAtColumn: clients.archivedAt,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Get by ID ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getClientById(id: string, portId: string) {
|
||||
const client = await db.query.clients.findFirst({
|
||||
where: eq(clients.id, id),
|
||||
});
|
||||
|
||||
if (!client || client.portId !== portId) {
|
||||
throw new NotFoundError('Client');
|
||||
}
|
||||
|
||||
const contacts = await db.query.clientContacts.findMany({
|
||||
where: eq(clientContacts.clientId, id),
|
||||
orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
|
||||
});
|
||||
|
||||
const clientTagRows = await db
|
||||
.select({ tag: tags })
|
||||
.from(clientTags)
|
||||
.innerJoin(tags, eq(clientTags.tagId, tags.id))
|
||||
.where(eq(clientTags.clientId, id));
|
||||
|
||||
return {
|
||||
...client,
|
||||
contacts,
|
||||
tags: clientTagRows.map((r) => r.tag),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Create ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createClient(
|
||||
portId: string,
|
||||
data: CreateClientInput,
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
const result = await withTransaction(async (tx) => {
|
||||
const { contacts: contactsInput, tagIds, ...clientData } = data;
|
||||
|
||||
const [client] = await tx
|
||||
.insert(clients)
|
||||
.values({ portId, ...clientData })
|
||||
.returning();
|
||||
|
||||
if (contactsInput.length > 0) {
|
||||
await tx.insert(clientContacts).values(
|
||||
contactsInput.map((c) => ({ clientId: client!.id, ...c })),
|
||||
);
|
||||
}
|
||||
|
||||
if (tagIds && tagIds.length > 0) {
|
||||
await tx.insert(clientTags).values(
|
||||
tagIds.map((tagId) => ({ clientId: client!.id, tagId })),
|
||||
);
|
||||
}
|
||||
|
||||
return client!;
|
||||
});
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'create',
|
||||
entityType: 'client',
|
||||
entityId: result.id,
|
||||
newValue: { fullName: result.fullName, companyName: result.companyName },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'client:created', { clientId: result.id, clientName: result.fullName ?? '', source: result.source ?? '' });
|
||||
|
||||
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
|
||||
dispatchWebhookEvent(portId, 'client:created', { clientId: result.id }),
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Update ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function updateClient(
|
||||
id: string,
|
||||
portId: string,
|
||||
data: UpdateClientInput,
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
const existing = await db.query.clients.findFirst({
|
||||
where: eq(clients.id, id),
|
||||
});
|
||||
|
||||
if (!existing || existing.portId !== portId) {
|
||||
throw new NotFoundError('Client');
|
||||
}
|
||||
|
||||
const { diff } = diffEntity(existing as Record<string, unknown>, data as Record<string, unknown>);
|
||||
|
||||
const [updated] = await db
|
||||
.update(clients)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(and(eq(clients.id, id), eq(clients.portId, portId)))
|
||||
.returning();
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'update',
|
||||
entityType: 'client',
|
||||
entityId: id,
|
||||
oldValue: diff as any,
|
||||
newValue: data as any,
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'client:updated', { clientId: id, changedFields: Object.keys(diff) });
|
||||
|
||||
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
|
||||
dispatchWebhookEvent(portId, 'client:updated', { clientId: id }),
|
||||
);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
// ─── Archive / Restore ────────────────────────────────────────────────────────
|
||||
|
||||
export async function archiveClient(id: string, portId: string, meta: AuditMeta) {
|
||||
const existing = await db.query.clients.findFirst({
|
||||
where: eq(clients.id, id),
|
||||
});
|
||||
|
||||
if (!existing || existing.portId !== portId) {
|
||||
throw new NotFoundError('Client');
|
||||
}
|
||||
|
||||
await softDelete(clients, clients.id, id);
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'archive',
|
||||
entityType: 'client',
|
||||
entityId: id,
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'client:archived', { clientId: id });
|
||||
|
||||
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
|
||||
dispatchWebhookEvent(portId, 'client:archived', { clientId: id }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function restoreClient(id: string, portId: string, meta: AuditMeta) {
|
||||
const existing = await db.query.clients.findFirst({
|
||||
where: eq(clients.id, id),
|
||||
});
|
||||
|
||||
if (!existing || existing.portId !== portId) {
|
||||
throw new NotFoundError('Client');
|
||||
}
|
||||
|
||||
await restore(clients, clients.id, id);
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'restore',
|
||||
entityType: 'client',
|
||||
entityId: id,
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'client:restored', { clientId: id });
|
||||
}
|
||||
|
||||
// ─── Contacts ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listContacts(clientId: string, portId: string) {
|
||||
const client = await db.query.clients.findFirst({
|
||||
where: eq(clients.id, clientId),
|
||||
});
|
||||
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
||||
|
||||
return db.query.clientContacts.findMany({
|
||||
where: eq(clientContacts.clientId, clientId),
|
||||
orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
|
||||
});
|
||||
}
|
||||
|
||||
export async function addContact(
|
||||
clientId: string,
|
||||
portId: string,
|
||||
data: { channel: string; value: string; label?: string; isPrimary?: boolean; notes?: string },
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
const client = await db.query.clients.findFirst({
|
||||
where: eq(clients.id, clientId),
|
||||
});
|
||||
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
||||
|
||||
const [contact] = await db
|
||||
.insert(clientContacts)
|
||||
.values({ clientId, ...data })
|
||||
.returning();
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'create',
|
||||
entityType: 'clientContact',
|
||||
entityId: contact!.id,
|
||||
newValue: { clientId, channel: contact!.channel },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] });
|
||||
|
||||
return contact!;
|
||||
}
|
||||
|
||||
export async function updateContact(
|
||||
contactId: string,
|
||||
clientId: string,
|
||||
portId: string,
|
||||
data: Partial<{ channel: string; value: string; label: string; isPrimary: boolean; notes: string }>,
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
const client = await db.query.clients.findFirst({
|
||||
where: eq(clients.id, clientId),
|
||||
});
|
||||
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
||||
|
||||
const contact = await db.query.clientContacts.findFirst({
|
||||
where: and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)),
|
||||
});
|
||||
if (!contact) throw new NotFoundError('Contact');
|
||||
|
||||
const [updated] = await db
|
||||
.update(clientContacts)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(clientContacts.id, contactId))
|
||||
.returning();
|
||||
|
||||
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] });
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function removeContact(
|
||||
contactId: string,
|
||||
clientId: string,
|
||||
portId: string,
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
const client = await db.query.clients.findFirst({
|
||||
where: eq(clients.id, clientId),
|
||||
});
|
||||
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
||||
|
||||
const contact = await db.query.clientContacts.findFirst({
|
||||
where: and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)),
|
||||
});
|
||||
if (!contact) throw new NotFoundError('Contact');
|
||||
|
||||
await db.delete(clientContacts).where(eq(clientContacts.id, contactId));
|
||||
|
||||
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] });
|
||||
}
|
||||
|
||||
// ─── Tags ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function setClientTags(
|
||||
clientId: string,
|
||||
portId: string,
|
||||
tagIds: string[],
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
const client = await db.query.clients.findFirst({
|
||||
where: eq(clients.id, clientId),
|
||||
});
|
||||
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
||||
|
||||
await db.delete(clientTags).where(eq(clientTags.clientId, clientId));
|
||||
|
||||
if (tagIds.length > 0) {
|
||||
await db.insert(clientTags).values(tagIds.map((tagId) => ({ clientId, tagId })));
|
||||
}
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'update',
|
||||
entityType: 'client',
|
||||
entityId: clientId,
|
||||
newValue: { tagIds },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['tags'] });
|
||||
}
|
||||
|
||||
// ─── Relationships ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listRelationships(clientId: string, portId: string) {
|
||||
const client = await db.query.clients.findFirst({
|
||||
where: eq(clients.id, clientId),
|
||||
});
|
||||
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
||||
|
||||
return db.query.clientRelationships.findMany({
|
||||
where: (r, { or, eq }) =>
|
||||
or(eq(r.clientAId, clientId), eq(r.clientBId, clientId)),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createRelationship(
|
||||
clientId: string,
|
||||
portId: string,
|
||||
data: { clientBId: string; relationshipType: string; description?: string },
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
const client = await db.query.clients.findFirst({
|
||||
where: eq(clients.id, clientId),
|
||||
});
|
||||
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
||||
|
||||
const [rel] = await db
|
||||
.insert(clientRelationships)
|
||||
.values({ portId, clientAId: clientId, ...data })
|
||||
.returning();
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'create',
|
||||
entityType: 'clientRelationship',
|
||||
entityId: rel!.id,
|
||||
newValue: { clientAId: clientId, clientBId: data.clientBId, type: data.relationshipType },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
return rel!;
|
||||
}
|
||||
|
||||
export async function deleteRelationship(
|
||||
relId: string,
|
||||
clientId: string,
|
||||
portId: string,
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
const rel = await db.query.clientRelationships.findFirst({
|
||||
where: eq(clientRelationships.id, relId),
|
||||
});
|
||||
if (!rel || rel.portId !== portId) throw new NotFoundError('Relationship');
|
||||
|
||||
await db.delete(clientRelationships).where(eq(clientRelationships.id, relId));
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'delete',
|
||||
entityType: 'clientRelationship',
|
||||
entityId: relId,
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Find Duplicates ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function findDuplicates(portId: string, fullName: string) {
|
||||
return db.query.clients.findMany({
|
||||
where: (c, { and, eq }) =>
|
||||
and(eq(c.portId, portId), ilike(c.fullName, `%${fullName}%`)),
|
||||
limit: 5,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Options (for comboboxes) ─────────────────────────────────────────────────
|
||||
|
||||
export async function listClientOptions(portId: string, search?: string) {
|
||||
const conditions = [eq(clients.portId, portId)];
|
||||
if (search) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(clients.fullName, `%${search}%`),
|
||||
ilike(clients.companyName, `%${search}%`),
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
return db
|
||||
.select({ id: clients.id, fullName: clients.fullName })
|
||||
.from(clients)
|
||||
.where(and(...conditions))
|
||||
.orderBy(clients.fullName)
|
||||
.limit(50);
|
||||
}
|
||||
Reference in New Issue
Block a user