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,22 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { exportBerthPdf } from '@/lib/services/record-export';
export const POST = withAuth(
withPermission('berths', 'view', async (req, ctx, params) => {
try {
const pdfBytes = await exportBerthPdf(params.id!, ctx.portId);
return new NextResponse(Buffer.from(pdfBytes), {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': 'attachment; filename="berth-spec.pdf"',
},
});
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { addMaintenanceLogSchema } from '@/lib/validators/berths';
import { getMaintenanceLogs, addMaintenanceLog } from '@/lib/services/berths.service';
import { errorResponse } from '@/lib/errors';
// GET /api/v1/berths/[id]/maintenance
export const GET = withAuth(
withPermission('berths', 'view', async (req, ctx, params) => {
try {
const logs = await getMaintenanceLogs(params.id!, ctx.portId);
return NextResponse.json({ data: logs });
} catch (error) {
return errorResponse(error);
}
}),
);
// POST /api/v1/berths/[id]/maintenance
export const POST = withAuth(
withPermission('berths', 'edit', async (req, ctx, params) => {
try {
const body = await parseBody(req, addMaintenanceLogSchema);
const log = await addMaintenanceLog(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: log }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { updateBerthSchema } from '@/lib/validators/berths';
import { getBerthById, updateBerth } from '@/lib/services/berths.service';
import { errorResponse } from '@/lib/errors';
// GET /api/v1/berths/[id]
export const GET = withAuth(
withPermission('berths', 'view', async (req, ctx, params) => {
try {
const berth = await getBerthById(params.id!, ctx.portId);
return NextResponse.json({ data: berth });
} catch (error) {
return errorResponse(error);
}
}),
);
// PATCH /api/v1/berths/[id] — update berth fields (no DELETE — import-only)
export const PATCH = withAuth(
withPermission('berths', 'edit', async (req, ctx, params) => {
try {
const body = await parseBody(req, updateBerthSchema);
const updated = await updateBerth(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: updated });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { updateBerthStatusSchema } from '@/lib/validators/berths';
import { updateBerthStatus } from '@/lib/services/berths.service';
import { errorResponse } from '@/lib/errors';
// PATCH /api/v1/berths/[id]/status
export const PATCH = withAuth(
withPermission('berths', 'edit', async (req, ctx, params) => {
try {
const body = await parseBody(req, updateBerthStatusSchema);
const updated = await updateBerthStatus(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: updated });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { setBerthTags } from '@/lib/services/berths.service';
import { errorResponse } from '@/lib/errors';
const setTagsSchema = z.object({
tagIds: z.array(z.string()),
});
// PUT /api/v1/berths/[id]/tags
export const PUT = withAuth(
withPermission('berths', 'edit', async (req, ctx, params) => {
try {
const body = await parseBody(req, setTagsSchema);
const result = await setBerthTags(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,90 @@
import { NextResponse } from 'next/server';
import { and, eq } from 'drizzle-orm';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { updateWaitingListSchema } from '@/lib/validators/berths';
import { reorderWaitingListSchema } from '@/lib/validators/interests';
import { getWaitingList, updateWaitingList } from '@/lib/services/berths.service';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { db } from '@/lib/db';
import { berthWaitingList } from '@/lib/db/schema/berths';
// GET /api/v1/berths/[id]/waiting-list
export const GET = withAuth(
withPermission('berths', 'view', async (req, ctx, params) => {
try {
const entries = await getWaitingList(params.id!, ctx.portId);
return NextResponse.json({ data: entries });
} catch (error) {
return errorResponse(error);
}
}),
);
// PUT /api/v1/berths/[id]/waiting-list
export const PUT = withAuth(
withPermission('berths', 'manage_waiting_list', async (req, ctx, params) => {
try {
const body = await parseBody(req, updateWaitingListSchema);
const entries = await updateWaitingList(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: entries });
} catch (error) {
return errorResponse(error);
}
}),
);
// PATCH /api/v1/berths/[id]/waiting-list — reorder a single entry
export const PATCH = withAuth(
withPermission('berths', 'manage_waiting_list', async (req, ctx, params) => {
try {
const body = await parseBody(req, reorderWaitingListSchema);
const berthId = params.id!;
const entry = await db.query.berthWaitingList.findFirst({
where: and(
eq(berthWaitingList.id, body.entryId),
eq(berthWaitingList.berthId, berthId),
),
});
if (!entry) throw new NotFoundError('Waiting list entry');
if (entry.position !== body.newPosition) {
// Fetch all entries sorted by position
const allEntries = await db
.select()
.from(berthWaitingList)
.where(eq(berthWaitingList.berthId, berthId))
.orderBy(berthWaitingList.position);
// Remove the moved entry then insert at newPosition (1-indexed)
const others = allEntries.filter((e) => e.id !== body.entryId);
others.splice(body.newPosition - 1, 0, entry);
// Update all positions in sequence
for (let i = 0; i < others.length; i++) {
await db
.update(berthWaitingList)
.set({ position: i + 1 })
.where(eq(berthWaitingList.id, others[i]!.id));
}
}
const entries = await db
.select()
.from(berthWaitingList)
.where(eq(berthWaitingList.berthId, berthId))
.orderBy(berthWaitingList.position);
return NextResponse.json({ data: entries });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { getBerthOptions } from '@/lib/services/berths.service';
import { errorResponse } from '@/lib/errors';
// GET /api/v1/berths/options — lightweight list for selects/comboboxes
export const GET = withAuth(async (req, ctx) => {
try {
const options = await getBerthOptions(ctx.portId);
return NextResponse.json({ data: options });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,36 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseQuery } from '@/lib/api/route-helpers';
import { listBerthsSchema } from '@/lib/validators/berths';
import { listBerths } from '@/lib/services/berths.service';
import { errorResponse } from '@/lib/errors';
// GET /api/v1/berths — list berths for the current port (no POST — import-only)
export const GET = withAuth(
withPermission('berths', 'view', async (req, ctx) => {
try {
const query = parseQuery(req, listBerthsSchema);
const result = await listBerths(ctx.portId, query);
const page = query.page;
const pageSize = query.limit;
const total = result.total;
const totalPages = Math.ceil(total / pageSize);
return NextResponse.json({
data: result.data,
pagination: {
page,
pageSize,
total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
});
} catch (error) {
return errorResponse(error);
}
}),
);