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

109
src/lib/db/query-builder.ts Normal file
View File

@@ -0,0 +1,109 @@
import {
and,
asc,
desc,
eq,
ilike,
isNull,
or,
sql,
type SQL,
} from 'drizzle-orm';
import type { PgTable, PgColumn } from 'drizzle-orm/pg-core';
import { db } from './index';
export interface BuildListQueryOptions {
table: PgTable;
portIdColumn: PgColumn;
portId: string;
idColumn: PgColumn;
updatedAtColumn: PgColumn;
filters?: SQL[];
sort?: { column: PgColumn; direction: 'asc' | 'desc' };
page: number;
pageSize: number;
searchColumns?: PgColumn[];
searchTerm?: string;
includeArchived?: boolean;
archivedAtColumn?: PgColumn;
}
export interface ListResult<T> {
data: T[];
total: number;
}
/**
* Generic Drizzle paginated query builder with port-scoping.
*
* - Port scoping is always the first condition in the AND chain.
* - `archivedAt IS NULL` by default (unless `includeArchived` is true).
* - Deterministic secondary sort: `updatedAt DESC, id DESC`.
*/
export async function buildListQuery<T>(
opts: BuildListQueryOptions,
): Promise<ListResult<T>> {
const {
table,
portIdColumn,
portId,
idColumn,
updatedAtColumn,
filters = [],
sort,
page,
pageSize,
searchColumns = [],
searchTerm,
includeArchived = false,
archivedAtColumn,
} = opts;
const conditions: SQL[] = [eq(portIdColumn, portId)];
// Exclude archived by default
if (!includeArchived && archivedAtColumn) {
conditions.push(isNull(archivedAtColumn));
}
// Full-text search across multiple columns via ILIKE
if (searchTerm && searchColumns.length > 0) {
const searchConditions = searchColumns.map((col) =>
ilike(col, `%${searchTerm}%`),
);
conditions.push(or(...searchConditions)!);
}
// Append caller-supplied filters
conditions.push(...filters);
const where = and(...conditions);
// Count total
const countResult = await db
.select({ count: sql<number>`count(*)::int` })
.from(table)
.where(where);
const total = countResult[0]?.count ?? 0;
// Build order by: user sort + deterministic secondary sort
const orderClauses: SQL[] = [];
if (sort) {
orderClauses.push(
sort.direction === 'asc' ? asc(sort.column) : desc(sort.column),
);
}
orderClauses.push(desc(updatedAtColumn), desc(idColumn));
// Fetch page
const offset = (page - 1) * pageSize;
const data = await db
.select()
.from(table)
.where(where)
.orderBy(...orderClauses)
.limit(pageSize)
.offset(offset);
return { data: data as T[], total };
}