Initial commit: Port Nimara CRM (Layers 0-4)
Some checks failed
Build & Push Docker Images / build-and-push (push) Has been cancelled
Build & Push Docker Images / deploy (push) Has been cancelled
Build & Push Docker Images / lint (push) Has been cancelled

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:
2026-03-26 11:52:51 +01:00
commit 67d7e6e3d5
572 changed files with 86496 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { linkBerth, unlinkBerth } from '@/lib/services/interests.service';
const linkBerthSchema = z.object({
berthId: z.string().min(1),
});
export const PUT = withAuth(
withPermission('interests', 'edit', async (req, ctx, params) => {
try {
const body = await parseBody(req, linkBerthSchema);
const interest = await linkBerth(params.id!, ctx.portId, body.berthId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: interest });
} catch (error) {
return errorResponse(error);
}
}),
);
export const DELETE = withAuth(
withPermission('interests', 'edit', async (req, ctx, params) => {
try {
const interest = await unlinkBerth(params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: interest });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,22 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { exportInterestPdf } from '@/lib/services/record-export';
export const POST = withAuth(
withPermission('interests', 'view', async (req, ctx, params) => {
try {
const pdfBytes = await exportInterestPdf(params.id!, ctx.portId);
return new NextResponse(Buffer.from(pdfBytes), {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': 'attachment; filename="interest-summary.pdf"',
},
});
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,63 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { updateNoteSchema } from '@/lib/validators/notes';
import * as notesService from '@/lib/services/notes.service';
export const PATCH = withAuth(
withPermission('interests', 'edit', async (req, ctx, params) => {
try {
const interestId = params.id;
const noteId = params.noteId;
if (!interestId) throw new NotFoundError('Interest');
if (!noteId) throw new NotFoundError('Note');
const body = await parseBody(req, updateNoteSchema);
const note = await notesService.update(ctx.portId, 'interests', interestId, noteId, body);
void createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'update',
entityType: 'interest_note',
entityId: noteId,
metadata: { interestId },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: note });
} catch (error) {
return errorResponse(error);
}
}),
);
export const DELETE = withAuth(
withPermission('interests', 'edit', async (_req, ctx, params) => {
try {
const interestId = params.id;
const noteId = params.noteId;
if (!interestId) throw new NotFoundError('Interest');
if (!noteId) throw new NotFoundError('Note');
await notesService.deleteNote(ctx.portId, 'interests', interestId, noteId);
void createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'delete',
entityType: 'interest_note',
entityId: noteId,
metadata: { interestId },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,55 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import { createNoteSchema } from '@/lib/validators/notes';
import * as notesService from '@/lib/services/notes.service';
export const GET = withAuth(
withPermission('interests', 'view', async (_req, ctx, params) => {
try {
const interestId = params.id;
if (!interestId) throw new NotFoundError('Interest');
const notes = await notesService.listForEntity(ctx.portId, 'interests', interestId);
return NextResponse.json({ data: notes });
} catch (error) {
return errorResponse(error);
}
}),
);
export const POST = withAuth(
withPermission('interests', 'edit', async (req, ctx, params) => {
try {
const interestId = params.id;
if (!interestId) throw new NotFoundError('Interest');
const body = await parseBody(req, createNoteSchema);
const note = await notesService.create(ctx.portId, 'interests', interestId, ctx.userId, body);
void createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'create',
entityType: 'interest_note',
entityId: note.id,
metadata: { interestId },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
emitToRoom(`interest:${interestId}`, 'interest:noteAdded', {
interestId,
noteId: note.id,
authorName: note.authorName ?? ctx.user.name,
preview: note.content.slice(0, 100),
});
return NextResponse.json({ data: note }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,22 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { generateRecommendations } from '@/lib/services/recommendations';
// POST /api/v1/interests/[id]/recommendations/generate
export const POST = withAuth(
withPermission('interests', 'edit', async (req, ctx, params) => {
try {
const recommendations = await generateRecommendations(params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: recommendations });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,39 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { listRecommendations, addManualRecommendation } from '@/lib/services/recommendations';
const addManualSchema = z.object({ berthId: z.string().min(1) });
// GET /api/v1/interests/[id]/recommendations
export const GET = withAuth(
withPermission('interests', 'view', async (req, ctx, params) => {
try {
const recommendations = await listRecommendations(params.id!, ctx.portId);
return NextResponse.json({ data: recommendations });
} catch (error) {
return errorResponse(error);
}
}),
);
// POST /api/v1/interests/[id]/recommendations — add manual recommendation
export const POST = withAuth(
withPermission('interests', 'edit', async (req, ctx, params) => {
try {
const body = await parseBody(req, addManualSchema);
const rec = await addManualRecommendation(params.id!, ctx.portId, body.berthId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: rec }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,21 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { restoreInterest } from '@/lib/services/interests.service';
export const POST = withAuth(
withPermission('interests', 'edit', async (req, ctx, params) => {
try {
await restoreInterest(params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ success: true });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,55 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import {
getInterestById,
updateInterest,
archiveInterest,
} from '@/lib/services/interests.service';
import { updateInterestSchema } from '@/lib/validators/interests';
export const GET = withAuth(
withPermission('interests', 'view', async (req, ctx, params) => {
try {
const interest = await getInterestById(params.id!, ctx.portId);
return NextResponse.json({ data: interest });
} catch (error) {
return errorResponse(error);
}
}),
);
export const PATCH = withAuth(
withPermission('interests', 'edit', async (req, ctx, params) => {
try {
const body = await parseBody(req, updateInterestSchema);
const interest = await updateInterest(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: interest });
} catch (error) {
return errorResponse(error);
}
}),
);
export const DELETE = withAuth(
withPermission('interests', 'delete', async (req, ctx, params) => {
try {
await archiveInterest(params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ success: true });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { changeInterestStage } from '@/lib/services/interests.service';
import { changeStageSchema } from '@/lib/validators/interests';
export const PATCH = withAuth(
withPermission('interests', 'change_stage', async (req, ctx, params) => {
try {
const body = await parseBody(req, changeStageSchema);
const interest = await changeInterestStage(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: interest });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,28 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { setInterestTags } from '@/lib/services/interests.service';
const setTagsSchema = z.object({
tagIds: z.array(z.string()),
});
export const PUT = withAuth(
withPermission('interests', 'edit', async (req, ctx, params) => {
try {
const body = await parseBody(req, setTagsSchema);
const result = await setInterestTags(params.id!, ctx.portId, body.tagIds, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,113 @@
import { NextResponse } from 'next/server';
import { and, eq, desc, inArray } from 'drizzle-orm';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { db } from '@/lib/db';
import { interests } from '@/lib/db/schema/interests';
import { auditLogs } from '@/lib/db/schema/system';
import { documents, documentEvents } from '@/lib/db/schema/documents';
interface TimelineEvent {
id: string;
type: 'audit' | 'document_event';
action: string;
description: string;
userId: string | null;
createdAt: Date;
metadata: Record<string, unknown>;
}
// GET /api/v1/interests/[id]/timeline
export const GET = withAuth(
withPermission('interests', 'view', async (req, ctx, params) => {
try {
const interestId = params.id!;
const interest = await db.query.interests.findFirst({
where: and(eq(interests.id, interestId), eq(interests.portId, ctx.portId)),
});
if (!interest) throw new NotFoundError('Interest');
// Fetch audit logs for this interest
const auditRows = await db
.select()
.from(auditLogs)
.where(
and(
eq(auditLogs.entityType, 'interest'),
eq(auditLogs.entityId, interestId),
),
)
.orderBy(desc(auditLogs.createdAt))
.limit(50);
// Fetch document events for documents linked to this interest
const interestDocs = await db
.select({ id: documents.id, title: documents.title })
.from(documents)
.where(eq(documents.interestId, interestId));
const docIds = interestDocs.map((d) => d.id);
const docEventRows =
docIds.length > 0
? await db
.select({
id: documentEvents.id,
documentId: documentEvents.documentId,
eventType: documentEvents.eventType,
eventData: documentEvents.eventData,
createdAt: documentEvents.createdAt,
})
.from(documentEvents)
.where(inArray(documentEvents.documentId, docIds))
.orderBy(desc(documentEvents.createdAt))
.limit(50)
: [];
const docTitles = Object.fromEntries(interestDocs.map((d) => [d.id, d.title]));
// Union and sort
const auditEvents: TimelineEvent[] = auditRows.map((row) => ({
id: row.id,
type: 'audit',
action: row.action,
description: buildAuditDescription(row.action, row.newValue as Record<string, unknown> | null),
userId: row.userId,
createdAt: row.createdAt,
metadata: (row.metadata as Record<string, unknown>) ?? {},
}));
const docEvents: TimelineEvent[] = docEventRows.map((row) => ({
id: row.id,
type: 'document_event',
action: row.eventType,
description: `Document "${docTitles[row.documentId] ?? row.documentId}": ${row.eventType}`,
userId: null,
createdAt: row.createdAt,
metadata: (row.eventData as Record<string, unknown>) ?? {},
}));
const allEvents = [...auditEvents, ...docEvents];
allEvents.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return NextResponse.json({ data: allEvents.slice(0, 50) });
} catch (error) {
return errorResponse(error);
}
}),
);
function buildAuditDescription(
action: string,
newValue: Record<string, unknown> | null,
): string {
if (action === 'create') return 'Interest created';
if (action === 'archive') return 'Interest archived';
if (action === 'restore') return 'Interest restored';
if (action === 'update' && newValue?.pipelineStage) {
return `Stage changed to "${newValue.pipelineStage}"`;
}
if (action === 'update') return 'Interest updated';
return action;
}

View File

@@ -0,0 +1,50 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseQuery, parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { listInterests, createInterest } from '@/lib/services/interests.service';
import { listInterestsSchema, createInterestSchema } from '@/lib/validators/interests';
export const GET = withAuth(
withPermission('interests', 'view', async (req, ctx) => {
try {
const query = parseQuery(req, listInterestsSchema);
const result = await listInterests(ctx.portId, query);
const { page, limit } = query;
const totalPages = Math.ceil(result.total / limit);
return NextResponse.json({
data: result.data,
pagination: {
page,
pageSize: limit,
total: result.total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
});
} catch (error) {
return errorResponse(error);
}
}),
);
export const POST = withAuth(
withPermission('interests', 'create', async (req, ctx) => {
try {
const body = await parseBody(req, createInterestSchema);
const interest = await createInterest(ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: interest }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);