2026-04-24 14:31:14 +02:00
|
|
|
import { and, eq, ilike, inArray, isNull, or } from 'drizzle-orm';
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
import { db } from '@/lib/db';
|
2026-04-24 14:31:14 +02:00
|
|
|
import { clients, clientContacts, clientRelationships, clientTags } from '@/lib/db/schema/clients';
|
|
|
|
|
import { companies, companyMemberships } from '@/lib/db/schema/companies';
|
|
|
|
|
import { yachts } from '@/lib/db/schema/yachts';
|
|
|
|
|
import { berthReservations } from '@/lib/db/schema/reservations';
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
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) {
|
2026-04-24 14:31:14 +02:00
|
|
|
const { page, limit, sort, order, search, includeArchived, source, nationality, tagIds } = query;
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
const filters = [];
|
|
|
|
|
|
|
|
|
|
if (source) {
|
|
|
|
|
filters.push(eq(clients.source, source));
|
|
|
|
|
}
|
|
|
|
|
if (nationality) {
|
|
|
|
|
filters.push(ilike(clients.nationality, `%${nationality}%`));
|
|
|
|
|
}
|
|
|
|
|
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));
|
|
|
|
|
|
2026-04-24 14:31:14 +02:00
|
|
|
const yachtRows = await db.query.yachts.findMany({
|
|
|
|
|
where: and(
|
|
|
|
|
eq(yachts.portId, portId),
|
|
|
|
|
eq(yachts.currentOwnerType, 'client'),
|
|
|
|
|
eq(yachts.currentOwnerId, id),
|
|
|
|
|
isNull(yachts.archivedAt),
|
|
|
|
|
),
|
|
|
|
|
columns: {
|
|
|
|
|
id: true,
|
|
|
|
|
name: true,
|
|
|
|
|
hullNumber: true,
|
|
|
|
|
registration: true,
|
|
|
|
|
lengthFt: true,
|
|
|
|
|
widthFt: true,
|
|
|
|
|
status: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const membershipRows = await db
|
|
|
|
|
.select({
|
|
|
|
|
membershipId: companyMemberships.id,
|
|
|
|
|
role: companyMemberships.role,
|
|
|
|
|
isPrimary: companyMemberships.isPrimary,
|
|
|
|
|
startDate: companyMemberships.startDate,
|
|
|
|
|
company: {
|
|
|
|
|
id: companies.id,
|
|
|
|
|
name: companies.name,
|
|
|
|
|
legalName: companies.legalName,
|
|
|
|
|
status: companies.status,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
.from(companyMemberships)
|
|
|
|
|
.innerJoin(companies, eq(companyMemberships.companyId, companies.id))
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(companyMemberships.clientId, id),
|
|
|
|
|
eq(companies.portId, portId),
|
|
|
|
|
isNull(companyMemberships.endDate),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const activeReservations = await db.query.berthReservations.findMany({
|
|
|
|
|
where: and(
|
|
|
|
|
eq(berthReservations.clientId, id),
|
|
|
|
|
eq(berthReservations.portId, portId),
|
|
|
|
|
eq(berthReservations.status, 'active'),
|
|
|
|
|
),
|
|
|
|
|
columns: {
|
|
|
|
|
id: true,
|
|
|
|
|
berthId: true,
|
|
|
|
|
yachtId: true,
|
|
|
|
|
startDate: true,
|
|
|
|
|
tenureType: true,
|
|
|
|
|
status: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
return {
|
|
|
|
|
...client,
|
|
|
|
|
contacts,
|
|
|
|
|
tags: clientTagRows.map((r) => r.tag),
|
2026-04-24 14:31:14 +02:00
|
|
|
yachts: yachtRows,
|
|
|
|
|
companies: membershipRows,
|
|
|
|
|
activeReservations,
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Create ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-04-24 14:31:14 +02:00
|
|
|
export async function createClient(portId: string, data: CreateClientInput, meta: AuditMeta) {
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
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) {
|
2026-04-24 14:31:14 +02:00
|
|
|
await tx
|
|
|
|
|
.insert(clientContacts)
|
|
|
|
|
.values(contactsInput.map((c) => ({ clientId: client!.id, ...c })));
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (tagIds && tagIds.length > 0) {
|
2026-04-24 14:31:14 +02:00
|
|
|
await tx.insert(clientTags).values(tagIds.map((tagId) => ({ clientId: client!.id, tagId })));
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-24 14:31:14 +02:00
|
|
|
emitToRoom(`port:${portId}`, 'client:created', {
|
|
|
|
|
clientId: result.id,
|
|
|
|
|
clientName: result.fullName ?? '',
|
|
|
|
|
source: result.source ?? '',
|
|
|
|
|
});
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
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,
|
2026-03-26 12:06:18 +01:00
|
|
|
oldValue: diff as Record<string, unknown>,
|
|
|
|
|
newValue: data as Record<string, unknown>,
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-24 14:31:14 +02:00
|
|
|
emitToRoom(`port:${portId}`, 'client:updated', {
|
|
|
|
|
clientId: id,
|
|
|
|
|
changedFields: Object.keys(diff),
|
|
|
|
|
});
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
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,
|
2026-04-24 14:31:14 +02:00
|
|
|
data: Partial<{
|
|
|
|
|
channel: string;
|
|
|
|
|
value: string;
|
|
|
|
|
label: string;
|
|
|
|
|
isPrimary: boolean;
|
|
|
|
|
notes: string;
|
|
|
|
|
}>,
|
2026-03-26 12:06:18 +01:00
|
|
|
_meta: AuditMeta,
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
) {
|
|
|
|
|
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,
|
2026-03-26 12:06:18 +01:00
|
|
|
_meta: AuditMeta,
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
) {
|
|
|
|
|
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({
|
2026-04-24 14:31:14 +02:00
|
|
|
where: (r, { or, eq }) => or(eq(r.clientAId, clientId), eq(r.clientBId, clientId)),
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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({
|
2026-04-24 14:31:14 +02:00
|
|
|
where: (c, { and, eq }) => and(eq(c.portId, portId), ilike(c.fullName, `%${fullName}%`)),
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
limit: 5,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Options (for comboboxes) ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function listClientOptions(portId: string, search?: string) {
|
|
|
|
|
const conditions = [eq(clients.portId, portId)];
|
|
|
|
|
if (search) {
|
|
|
|
|
conditions.push(
|
2026-04-24 14:31:14 +02:00
|
|
|
or(ilike(clients.fullName, `%${search}%`), ilike(clients.companyName, `%${search}%`))!,
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return db
|
|
|
|
|
.select({ id: clients.id, fullName: clients.fullName })
|
|
|
|
|
.from(clients)
|
|
|
|
|
.where(and(...conditions))
|
|
|
|
|
.orderBy(clients.fullName)
|
|
|
|
|
.limit(50);
|
|
|
|
|
}
|