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 { sql } from 'drizzle-orm';
|
|
|
|
|
|
|
|
|
|
import { db } from '@/lib/db';
|
|
|
|
|
import { redis } from '@/lib/redis';
|
|
|
|
|
|
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
interface ClientResult {
|
|
|
|
|
id: string;
|
|
|
|
|
fullName: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface InterestResult {
|
|
|
|
|
id: string;
|
|
|
|
|
clientName: string;
|
|
|
|
|
berthMooringNumber: string | null;
|
|
|
|
|
pipelineStage: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface BerthResult {
|
|
|
|
|
id: string;
|
|
|
|
|
mooringNumber: string;
|
|
|
|
|
area: string | null;
|
|
|
|
|
status: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 15:47:54 +02:00
|
|
|
interface YachtResult {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
hullNumber: string | null;
|
|
|
|
|
registration: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface CompanyResult {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
legalName: string | null;
|
|
|
|
|
taxId: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
interface SearchResults {
|
|
|
|
|
clients: ClientResult[];
|
|
|
|
|
interests: InterestResult[];
|
|
|
|
|
berths: BerthResult[];
|
2026-04-24 15:47:54 +02:00
|
|
|
yachts: YachtResult[];
|
|
|
|
|
companies: CompanyResult[];
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Search ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function search(portId: string, query: string): Promise<SearchResults> {
|
2026-04-24 15:47:54 +02:00
|
|
|
const [clientRows, berthRows, interestRows, yachtRows, companyRows] = await Promise.all([
|
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
|
|
|
// Clients: full-text search via tsvector
|
refactor(clients): drop deprecated yacht/company/proxy columns
PR 13: now that all reads are migrated to the dedicated yacht / company
/ membership entities, drop the columns that mirrored them on `clients`:
companyName, isProxy, proxyType, actualOwnerName, relationshipNotes,
yachtName, yachtLength{Ft,M}, yachtWidth{Ft,M}, yachtDraft{Ft,M},
berthSizeDesired.
Migration `0008_loud_ikaris.sql` issues the destructive ALTER TABLE
DROP COLUMN statements. Run `pnpm db:push` (or the migration runner) to
apply.
Caller cleanup (zero behavioral change to remaining flows):
- Drops the legacy `generateEoi` flow entirely (route, service function,
pdfme template, validator schema). The dual-path generate-and-sign
service from PR 11 has fully replaced it; the route was no longer
wired to the UI.
- `clients.service`: company-name search column / WHERE / audit value
removed; search now ranks by full name only.
- `interests.service`: `resolveLeadCategory` reads dimensions from
`yachts` via `interest.yachtId` instead of the dropped
`client.yachtLength{Ft,M}`.
- `record-export`: client-summary now lists yachts via owner-side
lookup (direct + active company memberships); interest-summary fetches
yacht via `interest.yachtId`. Both PDF templates updated to read
yacht details from the new entity.
- `client-detail-header`, `client-picker`, `command-search`,
`search-result-item`, `use-search` hook, `types/domain.ts`,
`search.service` — drop the companyName badge / sub-label / typed
field everywhere it was rendered or fetched.
- `ai.ts` worker: drop the company / yacht context lines from the
prompt (will be re-added later sourced from the new entities).
- `validators/interests.ts`: remove the deprecated public-form flat
yacht/company fields. The route already ignores them.
- `factories.ts`: drop the `isProxy: false` default.
Tests: 652/652 green; type-check clean. The
`security-sensitive-data` tests use `companyName` / `isProxy` as
arbitrary record keys for a generic util — left unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:57:54 +02:00
|
|
|
db.execute<{ id: string; full_name: string }>(sql`
|
|
|
|
|
SELECT id, full_name
|
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
|
|
|
FROM clients
|
|
|
|
|
WHERE port_id = ${portId}
|
|
|
|
|
AND archived_at IS NULL
|
refactor(clients): drop deprecated yacht/company/proxy columns
PR 13: now that all reads are migrated to the dedicated yacht / company
/ membership entities, drop the columns that mirrored them on `clients`:
companyName, isProxy, proxyType, actualOwnerName, relationshipNotes,
yachtName, yachtLength{Ft,M}, yachtWidth{Ft,M}, yachtDraft{Ft,M},
berthSizeDesired.
Migration `0008_loud_ikaris.sql` issues the destructive ALTER TABLE
DROP COLUMN statements. Run `pnpm db:push` (or the migration runner) to
apply.
Caller cleanup (zero behavioral change to remaining flows):
- Drops the legacy `generateEoi` flow entirely (route, service function,
pdfme template, validator schema). The dual-path generate-and-sign
service from PR 11 has fully replaced it; the route was no longer
wired to the UI.
- `clients.service`: company-name search column / WHERE / audit value
removed; search now ranks by full name only.
- `interests.service`: `resolveLeadCategory` reads dimensions from
`yachts` via `interest.yachtId` instead of the dropped
`client.yachtLength{Ft,M}`.
- `record-export`: client-summary now lists yachts via owner-side
lookup (direct + active company memberships); interest-summary fetches
yacht via `interest.yachtId`. Both PDF templates updated to read
yacht details from the new entity.
- `client-detail-header`, `client-picker`, `command-search`,
`search-result-item`, `use-search` hook, `types/domain.ts`,
`search.service` — drop the companyName badge / sub-label / typed
field everywhere it was rendered or fetched.
- `ai.ts` worker: drop the company / yacht context lines from the
prompt (will be re-added later sourced from the new entities).
- `validators/interests.ts`: remove the deprecated public-form flat
yacht/company fields. The route already ignores them.
- `factories.ts`: drop the `isProxy: false` default.
Tests: 652/652 green; type-check clean. The
`security-sensitive-data` tests use `companyName` / `isProxy` as
arbitrary record keys for a generic util — left unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:57:54 +02:00
|
|
|
AND to_tsvector('simple', coalesce(full_name, ''))
|
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
|
|
|
@@ plainto_tsquery('simple', ${query})
|
|
|
|
|
ORDER BY ts_rank(
|
refactor(clients): drop deprecated yacht/company/proxy columns
PR 13: now that all reads are migrated to the dedicated yacht / company
/ membership entities, drop the columns that mirrored them on `clients`:
companyName, isProxy, proxyType, actualOwnerName, relationshipNotes,
yachtName, yachtLength{Ft,M}, yachtWidth{Ft,M}, yachtDraft{Ft,M},
berthSizeDesired.
Migration `0008_loud_ikaris.sql` issues the destructive ALTER TABLE
DROP COLUMN statements. Run `pnpm db:push` (or the migration runner) to
apply.
Caller cleanup (zero behavioral change to remaining flows):
- Drops the legacy `generateEoi` flow entirely (route, service function,
pdfme template, validator schema). The dual-path generate-and-sign
service from PR 11 has fully replaced it; the route was no longer
wired to the UI.
- `clients.service`: company-name search column / WHERE / audit value
removed; search now ranks by full name only.
- `interests.service`: `resolveLeadCategory` reads dimensions from
`yachts` via `interest.yachtId` instead of the dropped
`client.yachtLength{Ft,M}`.
- `record-export`: client-summary now lists yachts via owner-side
lookup (direct + active company memberships); interest-summary fetches
yacht via `interest.yachtId`. Both PDF templates updated to read
yacht details from the new entity.
- `client-detail-header`, `client-picker`, `command-search`,
`search-result-item`, `use-search` hook, `types/domain.ts`,
`search.service` — drop the companyName badge / sub-label / typed
field everywhere it was rendered or fetched.
- `ai.ts` worker: drop the company / yacht context lines from the
prompt (will be re-added later sourced from the new entities).
- `validators/interests.ts`: remove the deprecated public-form flat
yacht/company fields. The route already ignores them.
- `factories.ts`: drop the `isProxy: false` default.
Tests: 652/652 green; type-check clean. The
`security-sensitive-data` tests use `companyName` / `isProxy` as
arbitrary record keys for a generic util — left unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:57:54 +02:00
|
|
|
to_tsvector('simple', coalesce(full_name, '')),
|
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
|
|
|
plainto_tsquery('simple', ${query})
|
|
|
|
|
) DESC
|
|
|
|
|
LIMIT 10
|
|
|
|
|
`),
|
|
|
|
|
|
|
|
|
|
// Berths: trigram similarity on mooring_number
|
|
|
|
|
db.execute<{ id: string; mooring_number: string; area: string | null; status: string }>(sql`
|
|
|
|
|
SELECT id, mooring_number, area, status
|
|
|
|
|
FROM berths
|
|
|
|
|
WHERE port_id = ${portId}
|
|
|
|
|
AND mooring_number % ${query}
|
|
|
|
|
ORDER BY similarity(mooring_number, ${query}) DESC
|
|
|
|
|
LIMIT 10
|
|
|
|
|
`),
|
|
|
|
|
|
|
|
|
|
// Interests: JOIN to clients and berths, ILIKE search
|
|
|
|
|
db.execute<{
|
|
|
|
|
id: string;
|
|
|
|
|
full_name: string;
|
|
|
|
|
mooring_number: string | null;
|
|
|
|
|
pipeline_stage: string;
|
|
|
|
|
}>(sql`
|
|
|
|
|
SELECT
|
|
|
|
|
i.id,
|
|
|
|
|
c.full_name,
|
|
|
|
|
b.mooring_number,
|
|
|
|
|
i.pipeline_stage
|
|
|
|
|
FROM interests i
|
|
|
|
|
JOIN clients c ON i.client_id = c.id
|
|
|
|
|
LEFT JOIN berths b ON i.berth_id = b.id
|
|
|
|
|
WHERE i.port_id = ${portId}
|
|
|
|
|
AND i.archived_at IS NULL
|
|
|
|
|
AND (
|
|
|
|
|
c.full_name ILIKE ${'%' + query + '%'}
|
|
|
|
|
OR b.mooring_number ILIKE ${'%' + query + '%'}
|
|
|
|
|
)
|
|
|
|
|
LIMIT 10
|
|
|
|
|
`),
|
2026-04-24 15:47:54 +02:00
|
|
|
|
|
|
|
|
// Yachts: ILIKE on name, hull_number, registration
|
|
|
|
|
db.execute<{
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
hull_number: string | null;
|
|
|
|
|
registration: string | null;
|
|
|
|
|
}>(sql`
|
|
|
|
|
SELECT id, name, hull_number, registration
|
|
|
|
|
FROM yachts
|
|
|
|
|
WHERE port_id = ${portId}
|
|
|
|
|
AND archived_at IS NULL
|
|
|
|
|
AND (
|
|
|
|
|
name ILIKE ${'%' + query + '%'}
|
|
|
|
|
OR hull_number ILIKE ${'%' + query + '%'}
|
|
|
|
|
OR registration ILIKE ${'%' + query + '%'}
|
|
|
|
|
)
|
|
|
|
|
ORDER BY
|
|
|
|
|
CASE
|
|
|
|
|
WHEN name ILIKE ${query + '%'} THEN 1
|
|
|
|
|
WHEN name ILIKE ${'%' + query + '%'} THEN 2
|
|
|
|
|
ELSE 3
|
|
|
|
|
END,
|
|
|
|
|
name
|
|
|
|
|
LIMIT 10
|
|
|
|
|
`),
|
|
|
|
|
|
|
|
|
|
// Companies: ILIKE on name, legal_name, tax_id
|
|
|
|
|
db.execute<{
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
legal_name: string | null;
|
|
|
|
|
tax_id: string | null;
|
|
|
|
|
}>(sql`
|
|
|
|
|
SELECT id, name, legal_name, tax_id
|
|
|
|
|
FROM companies
|
|
|
|
|
WHERE port_id = ${portId}
|
|
|
|
|
AND archived_at IS NULL
|
|
|
|
|
AND (
|
|
|
|
|
name ILIKE ${'%' + query + '%'}
|
|
|
|
|
OR legal_name ILIKE ${'%' + query + '%'}
|
|
|
|
|
OR tax_id ILIKE ${'%' + query + '%'}
|
|
|
|
|
)
|
|
|
|
|
ORDER BY
|
|
|
|
|
CASE
|
|
|
|
|
WHEN name ILIKE ${query + '%'} THEN 1
|
|
|
|
|
WHEN name ILIKE ${'%' + query + '%'} THEN 2
|
|
|
|
|
ELSE 3
|
|
|
|
|
END,
|
|
|
|
|
name
|
|
|
|
|
LIMIT 10
|
|
|
|
|
`),
|
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 {
|
|
|
|
|
clients: Array.from(clientRows).map((r) => ({
|
|
|
|
|
id: r.id,
|
|
|
|
|
fullName: r.full_name,
|
|
|
|
|
})),
|
|
|
|
|
berths: Array.from(berthRows).map((r) => ({
|
|
|
|
|
id: r.id,
|
|
|
|
|
mooringNumber: r.mooring_number,
|
|
|
|
|
area: r.area ?? null,
|
|
|
|
|
status: r.status,
|
|
|
|
|
})),
|
|
|
|
|
interests: Array.from(interestRows).map((r) => ({
|
|
|
|
|
id: r.id,
|
|
|
|
|
clientName: r.full_name,
|
|
|
|
|
berthMooringNumber: r.mooring_number ?? null,
|
|
|
|
|
pipelineStage: r.pipeline_stage,
|
|
|
|
|
})),
|
2026-04-24 15:47:54 +02:00
|
|
|
yachts: Array.from(yachtRows).map((r) => ({
|
|
|
|
|
id: r.id,
|
|
|
|
|
name: r.name,
|
|
|
|
|
hullNumber: r.hull_number ?? null,
|
|
|
|
|
registration: r.registration ?? null,
|
|
|
|
|
})),
|
|
|
|
|
companies: Array.from(companyRows).map((r) => ({
|
|
|
|
|
id: r.id,
|
|
|
|
|
name: r.name,
|
|
|
|
|
legalName: r.legal_name ?? null,
|
|
|
|
|
taxId: r.tax_id ?? null,
|
|
|
|
|
})),
|
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
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Recent Searches ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const RECENT_SEARCH_TTL = 2592000; // 30 days in seconds
|
|
|
|
|
const RECENT_SEARCH_MAX = 10;
|
|
|
|
|
|
|
|
|
|
function recentSearchKey(userId: string, portId: string): string {
|
|
|
|
|
return `recent-search:${userId}:${portId}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Fire-and-forget — saves a search term to the user's recent searches sorted set.
|
|
|
|
|
*/
|
|
|
|
|
export function saveRecentSearch(userId: string, portId: string, searchTerm: string): void {
|
|
|
|
|
const key = recentSearchKey(userId, portId);
|
|
|
|
|
redis
|
|
|
|
|
.zadd(key, Date.now(), searchTerm)
|
|
|
|
|
.then(() => redis.zremrangebyrank(key, 0, -(RECENT_SEARCH_MAX + 1)))
|
|
|
|
|
.then(() => redis.expire(key, RECENT_SEARCH_TTL))
|
|
|
|
|
.catch(() => {
|
|
|
|
|
// Intentionally swallowed — recent searches are non-critical
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Returns the user's most recent searches, newest first.
|
|
|
|
|
*/
|
|
|
|
|
export async function getRecentSearches(userId: string, portId: string): Promise<string[]> {
|
|
|
|
|
const key = recentSearchKey(userId, portId);
|
|
|
|
|
const items = await redis.zrevrange(key, 0, RECENT_SEARCH_MAX - 1);
|
|
|
|
|
return items;
|
|
|
|
|
}
|