chore(cleanup): Phase 1 — gap closure across audit, alerts, soft-delete, perms
Multi-area cleanup pass closing partial-implementation gaps surfaced by the
post-i18n audit. No behavior changes for happy-path users; closes real
correctness/security holes.
PR1a Public yacht-interest endpoint i18n. /api/public/interests now accepts
phoneE164/phoneCountry, nationalityIso, address.{countryIso, subdivisionIso},
and company.{incorporationCountryIso, incorporationSubdivisionIso}.
Server-side parsePhone() fallback for legacy raw phone strings.
PR1b Alert rule registry trim. Two rule slots ('document.expiring_soon',
'audit.suspicious_login') were registered but evaluators returned [].
Both required schema/instrumentation that hadn't landed. Removed from
the registry; comments record the dependencies needed to revive them.
Effective rule count: 8 active.
PR1c vi.mock hoist + flake fix. Hoisted vi.mock calls to top-level in 5
integration test files; webhook-delivery uses vi.hoisted for the
queue-add ref. Vitest no longer warns about non-top-level mocks.
Deflaked the 'short value' assertion in security-encryption.test.ts
by switching plaintext from 'ab' to 'XY' (non-hex chars). 5/5 runs green.
PR1d Soft-delete reference audit. listClientOptions and listYachtsForOwner
now filter by isNull(archivedAt). Berths use status (no archivedAt).
PR1e Permission-matrix audit script + report. scripts/audit-permissions.ts
walks every src/app/api/v1/**/route.ts and reports handlers without a
withPermission() wrapper. Initial run found 33 violations.
- Allow-listed 17 with explicit reasons (self-data, admin, alerts,
search, currency, ai, custom-fields — some marked TODO).
- Wrapped 7 routes with concrete permissions: clients/options
(clients:view), berths/options (berths:view), dashboard/*
(reports:view_dashboard), analytics (reports:view_analytics).
Audit report at docs/runbooks/permission-audit.md. Script exits
non-zero on any unallow-listed violation so it can become a CI gate.
Vitest: 741 -> 741 (no new tests; existing suite covers the changes).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
56
docs/runbooks/permission-audit.md
Normal file
56
docs/runbooks/permission-audit.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Permission Matrix Audit
|
||||
|
||||
Scanned 182 route files under `src/app/api/v1/`.
|
||||
|
||||
**No violations.** Every internal v1 handler is permission-gated.
|
||||
|
||||
**Allow-listed:** 46 handler(s) intentionally skip `withPermission`.
|
||||
|
||||
| File | Method | Reason |
|
||||
| ---------------------------------------------------------------- | ------ | --------------------------------------------------------------------------------- |
|
||||
| `src/app/api/v1/admin/alerts/run-engine/route.ts` | POST | Admin-only — gated by isSuperAdmin inside handler. |
|
||||
| `src/app/api/v1/admin/connections/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
|
||||
| `src/app/api/v1/admin/errors/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
|
||||
| `src/app/api/v1/admin/health/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
|
||||
| `src/app/api/v1/admin/ocr-settings/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
|
||||
| `src/app/api/v1/admin/ocr-settings/route.ts` | PUT | Admin-only — gated by isSuperAdmin inside handler. |
|
||||
| `src/app/api/v1/admin/ocr-settings/test/route.ts` | POST | Admin-only — gated by isSuperAdmin inside handler. |
|
||||
| `src/app/api/v1/admin/queues/[queueName]/[jobId]/retry/route.ts` | POST | Admin-only — gated by isSuperAdmin inside handler. |
|
||||
| `src/app/api/v1/admin/queues/[queueName]/[jobId]/route.ts` | DELETE | Admin-only — gated by isSuperAdmin inside handler. |
|
||||
| `src/app/api/v1/admin/queues/[queueName]/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
|
||||
| `src/app/api/v1/admin/queues/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
|
||||
| `src/app/api/v1/admin/users/options/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
|
||||
| `src/app/api/v1/ai/email-draft/[jobId]/route.ts` | GET | TODO: needs ai:\* permission catalog entry. Currently allow-listed. |
|
||||
| `src/app/api/v1/ai/email-draft/route.ts` | POST | TODO: needs ai:\* permission catalog entry. Currently allow-listed. |
|
||||
| `src/app/api/v1/ai/interest-score/bulk/route.ts` | GET | TODO: needs ai:\* permission catalog entry. Currently allow-listed. |
|
||||
| `src/app/api/v1/ai/interest-score/route.ts` | GET | TODO: needs ai:\* permission catalog entry. Currently allow-listed. |
|
||||
| `src/app/api/v1/alerts/[id]/acknowledge/route.ts` | POST | Alerts are user-scoped; port-filtered via auth context. |
|
||||
| `src/app/api/v1/alerts/[id]/dismiss/route.ts` | POST | Alerts are user-scoped; port-filtered via auth context. |
|
||||
| `src/app/api/v1/alerts/count/route.ts` | GET | Alerts are user-scoped; port-filtered via auth context. |
|
||||
| `src/app/api/v1/alerts/route.ts` | GET | Alerts are user-scoped; port-filtered via auth context. |
|
||||
| `src/app/api/v1/berth-reservations/[id]/route.ts` | PATCH | TODO: PATCH should map to reservations:edit (not currently in catalog). |
|
||||
| `src/app/api/v1/currency/convert/route.ts` | POST | Currency reference data; port-scoped, no PII. |
|
||||
| `src/app/api/v1/currency/rates/refresh/route.ts` | POST | TODO: gate with admin:manage_settings — currently allow-listed. |
|
||||
| `src/app/api/v1/currency/rates/route.ts` | GET | Currency reference data; port-scoped, no PII. |
|
||||
| `src/app/api/v1/custom-fields/[entityId]/route.ts` | GET | TODO: needs custom_fields:\* permission. PUT path internally validated. |
|
||||
| `src/app/api/v1/custom-fields/[entityId]/route.ts` | PUT | TODO: needs custom_fields:\* permission. PUT path internally validated. |
|
||||
| `src/app/api/v1/expenses/export/parent-company/route.ts` | POST | Internally gated by isSuperAdmin inside the handler. |
|
||||
| `src/app/api/v1/me/route.ts` | GET | Self-endpoint — auth is sufficient. |
|
||||
| `src/app/api/v1/me/route.ts` | PATCH | Self-endpoint — auth is sufficient. |
|
||||
| `src/app/api/v1/notifications/[notificationId]/route.ts` | PATCH | User-scoped notifications — caller is the resource owner. |
|
||||
| `src/app/api/v1/notifications/preferences/route.ts` | GET | User-scoped notifications — caller is the resource owner. |
|
||||
| `src/app/api/v1/notifications/preferences/route.ts` | PUT | User-scoped notifications — caller is the resource owner. |
|
||||
| `src/app/api/v1/notifications/read-all/route.ts` | POST | User-scoped notifications — caller is the resource owner. |
|
||||
| `src/app/api/v1/notifications/route.ts` | GET | User-scoped notifications — caller is the resource owner. |
|
||||
| `src/app/api/v1/notifications/unread-count/route.ts` | GET | User-scoped notifications — caller is the resource owner. |
|
||||
| `src/app/api/v1/saved-views/[id]/route.ts` | PATCH | User-self saved views — caller is the resource owner. |
|
||||
| `src/app/api/v1/saved-views/[id]/route.ts` | DELETE | User-self saved views — caller is the resource owner. |
|
||||
| `src/app/api/v1/saved-views/route.ts` | GET | User-self saved views — caller is the resource owner. |
|
||||
| `src/app/api/v1/saved-views/route.ts` | POST | User-self saved views — caller is the resource owner. |
|
||||
| `src/app/api/v1/search/recent/route.ts` | GET | Port-scoped search — results filtered by auth context (resources have own perms). |
|
||||
| `src/app/api/v1/search/route.ts` | GET | Port-scoped search — results filtered by auth context (resources have own perms). |
|
||||
| `src/app/api/v1/settings/feature-flag/route.ts` | GET | Public read of feature-flag bool — no PII; auth is sufficient. |
|
||||
| `src/app/api/v1/tags/options/route.ts` | GET | Tags are cross-cutting reference data; port-scoped via auth. |
|
||||
| `src/app/api/v1/tags/route.ts` | GET | Tags are cross-cutting reference data; port-scoped via auth. |
|
||||
| `src/app/api/v1/users/me/preferences/route.ts` | GET | User-self preferences — caller is the resource owner. |
|
||||
| `src/app/api/v1/users/me/preferences/route.ts` | PATCH | User-self preferences — caller is the resource owner. |
|
||||
188
scripts/audit-permissions.ts
Normal file
188
scripts/audit-permissions.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Permission-matrix audit.
|
||||
*
|
||||
* Walks every src/app/api/v1/** /route.ts file and reports each exported HTTP
|
||||
* handler (GET/POST/PUT/PATCH/DELETE) that is *not* wrapped in withPermission().
|
||||
* Internal v1 routes should be permission-gated; routes that intentionally use
|
||||
* withAuth() alone (e.g. user-self endpoints) can be allow-listed below.
|
||||
*
|
||||
* Run:
|
||||
* pnpm tsx scripts/audit-permissions.ts
|
||||
*
|
||||
* Exit code:
|
||||
* 0 — every handler is permission-gated or in the allow-list
|
||||
* 1 — at least one handler is missing both a withPermission wrapper and an
|
||||
* allow-list entry. CI should fail.
|
||||
*/
|
||||
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { join, relative } from 'node:path';
|
||||
|
||||
const ROOT = join(process.cwd(), 'src/app/api/v1');
|
||||
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const;
|
||||
|
||||
/**
|
||||
* Routes intentionally exempt from withPermission. Each entry should explain
|
||||
* why — typically because the route operates on the caller's own resources
|
||||
* (no port-level permission semantics) or is admin-only and gated by
|
||||
* isSuperAdmin inside the handler.
|
||||
*/
|
||||
const ALLOW_LIST: ReadonlyArray<{ pattern: RegExp; reason: string }> = [
|
||||
// Self / admin / public
|
||||
{ pattern: /\/me\/route\.ts$/, reason: 'Self-endpoint — auth is sufficient.' },
|
||||
{ pattern: /\/admin\//, reason: 'Admin-only — gated by isSuperAdmin inside handler.' },
|
||||
{
|
||||
pattern: /\/notifications\//,
|
||||
reason: 'User-scoped notifications — caller is the resource owner.',
|
||||
},
|
||||
{ pattern: /\/socket\//, reason: 'Socket auth handshake.' },
|
||||
{ pattern: /\/health\//, reason: 'Public health check.' },
|
||||
{ pattern: /\/users\/me\//, reason: 'User-self preferences — caller is the resource owner.' },
|
||||
{ pattern: /\/saved-views\//, reason: 'User-self saved views — caller is the resource owner.' },
|
||||
{
|
||||
pattern: /\/settings\/feature-flag\//,
|
||||
reason: 'Public read of feature-flag bool — no PII; auth is sufficient.',
|
||||
},
|
||||
// Cross-cutting / port-scoped reference data
|
||||
{ pattern: /\/tags\//, reason: 'Tags are cross-cutting reference data; port-scoped via auth.' },
|
||||
{
|
||||
pattern: /\/currency\/(convert|rates)\/route\.ts$/,
|
||||
reason: 'Currency reference data; port-scoped, no PII.',
|
||||
},
|
||||
{
|
||||
pattern: /\/currency\/rates\/refresh\//,
|
||||
reason: 'TODO: gate with admin:manage_settings — currently allow-listed.',
|
||||
},
|
||||
{
|
||||
pattern: /\/search\//,
|
||||
reason: 'Port-scoped search — results filtered by auth context (resources have own perms).',
|
||||
},
|
||||
// Alerts surface in topbar/dashboard for every signed-in user; per-port not per-resource.
|
||||
{ pattern: /\/alerts\//, reason: 'Alerts are user-scoped; port-filtered via auth context.' },
|
||||
// Internally gated by isSuperAdmin
|
||||
{
|
||||
pattern: /\/expenses\/export\/parent-company\//,
|
||||
reason: 'Internally gated by isSuperAdmin inside the handler.',
|
||||
},
|
||||
// Pending dedicated permissions
|
||||
{
|
||||
pattern: /\/ai\//,
|
||||
reason: 'TODO: needs ai:* permission catalog entry. Currently allow-listed.',
|
||||
},
|
||||
{
|
||||
pattern: /\/custom-fields\/\[entityId\]\//,
|
||||
reason: 'TODO: needs custom_fields:* permission. PUT path internally validated.',
|
||||
},
|
||||
{
|
||||
pattern: /\/berth-reservations\/\[id\]\/route\.ts$/,
|
||||
reason: 'TODO: PATCH should map to reservations:edit (not currently in catalog).',
|
||||
},
|
||||
];
|
||||
|
||||
interface Finding {
|
||||
file: string;
|
||||
method: string;
|
||||
reason: 'no-withPermission' | 'no-withAuth' | 'allow-listed';
|
||||
allowReason?: string;
|
||||
}
|
||||
|
||||
async function* walk(dir: string): AsyncGenerator<string> {
|
||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) yield* walk(path);
|
||||
else if (entry.isFile() && entry.name === 'route.ts') yield path;
|
||||
}
|
||||
}
|
||||
|
||||
function isAllowListed(file: string): { allowed: boolean; reason?: string } {
|
||||
for (const { pattern, reason } of ALLOW_LIST) {
|
||||
if (pattern.test(file)) return { allowed: true, reason };
|
||||
}
|
||||
return { allowed: false };
|
||||
}
|
||||
|
||||
async function auditFile(file: string): Promise<Finding[]> {
|
||||
const src = await readFile(file, 'utf-8');
|
||||
const findings: Finding[] = [];
|
||||
|
||||
for (const method of HTTP_METHODS) {
|
||||
// Match: export const GET = withAuth(...
|
||||
const declRe = new RegExp(`export\\s+const\\s+${method}\\s*=\\s*(.+?);`, 's');
|
||||
const m = declRe.exec(src);
|
||||
if (!m) continue;
|
||||
const block = m[1] ?? '';
|
||||
|
||||
const hasAuth = /withAuth\s*\(/.test(block);
|
||||
const hasPerm = /withPermission\s*\(/.test(block);
|
||||
const allow = isAllowListed(file);
|
||||
|
||||
if (!hasAuth) {
|
||||
findings.push({ file, method, reason: 'no-withAuth' });
|
||||
continue;
|
||||
}
|
||||
if (!hasPerm) {
|
||||
if (allow.allowed) {
|
||||
findings.push({ file, method, reason: 'allow-listed', allowReason: allow.reason });
|
||||
} else {
|
||||
findings.push({ file, method, reason: 'no-withPermission' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const files: string[] = [];
|
||||
for await (const f of walk(ROOT)) files.push(f);
|
||||
files.sort();
|
||||
|
||||
const all: Finding[] = [];
|
||||
for (const f of files) all.push(...(await auditFile(f)));
|
||||
|
||||
const violations = all.filter(
|
||||
(f) => f.reason === 'no-withPermission' || f.reason === 'no-withAuth',
|
||||
);
|
||||
const allowListed = all.filter((f) => f.reason === 'allow-listed');
|
||||
|
||||
// Markdown report
|
||||
const lines: string[] = [];
|
||||
lines.push('# Permission Matrix Audit');
|
||||
lines.push('');
|
||||
lines.push(`Scanned ${files.length} route files under \`src/app/api/v1/\`.`);
|
||||
lines.push('');
|
||||
|
||||
if (violations.length === 0) {
|
||||
lines.push('**No violations.** Every internal v1 handler is permission-gated.');
|
||||
} else {
|
||||
lines.push(`**${violations.length} violation(s):**`);
|
||||
lines.push('');
|
||||
lines.push('| File | Method | Issue |');
|
||||
lines.push('| --- | --- | --- |');
|
||||
for (const v of violations) {
|
||||
const rel = relative(process.cwd(), v.file);
|
||||
lines.push(`| \`${rel}\` | ${v.method} | ${v.reason} |`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
lines.push(
|
||||
`**Allow-listed:** ${allowListed.length} handler(s) intentionally skip \`withPermission\`.`,
|
||||
);
|
||||
if (allowListed.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('| File | Method | Reason |');
|
||||
lines.push('| --- | --- | --- |');
|
||||
for (const a of allowListed) {
|
||||
const rel = relative(process.cwd(), a.file);
|
||||
lines.push(`| \`${rel}\` | ${a.method} | ${a.allowReason} |`);
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(lines.join('\n') + '\n');
|
||||
process.exit(violations.length > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(2);
|
||||
});
|
||||
@@ -14,6 +14,8 @@ import { createAuditLog } from '@/lib/audit';
|
||||
import { errorResponse, RateLimitError } from '@/lib/errors';
|
||||
import { publicInterestSchema } from '@/lib/validators/interests';
|
||||
import { sendInquiryNotifications } from '@/lib/services/inquiry-notifications.service';
|
||||
import { parsePhone } from '@/lib/i18n/phone';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
|
||||
// ─── Simple in-memory rate limiter ───────────────────────────────────────────
|
||||
// Max 5 requests per hour per IP
|
||||
@@ -61,6 +63,16 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Port context required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Server-side phone normalization for older website builds that post raw
|
||||
// international/national strings. Newer builds may pre-fill phoneE164/Country.
|
||||
let phoneE164 = data.phoneE164 ?? null;
|
||||
let phoneCountry: CountryCode | null = (data.phoneCountry as CountryCode | null) ?? null;
|
||||
if (!phoneE164) {
|
||||
const parsed = parsePhone(data.phone, phoneCountry ?? undefined);
|
||||
phoneE164 = parsed.e164;
|
||||
phoneCountry = parsed.country ?? phoneCountry;
|
||||
}
|
||||
|
||||
const fullName =
|
||||
data.firstName && data.lastName
|
||||
? `${data.firstName} ${data.lastName}`
|
||||
@@ -96,17 +108,21 @@ export async function POST(req: NextRequest) {
|
||||
});
|
||||
if (existingClient && existingClient.portId === portId) {
|
||||
clientId = existingClient.id;
|
||||
const updates: Partial<typeof clients.$inferInsert> = {};
|
||||
if (data.preferredContactMethod) {
|
||||
await tx
|
||||
.update(clients)
|
||||
.set({ preferredContactMethod: data.preferredContactMethod })
|
||||
.where(eq(clients.id, clientId));
|
||||
updates.preferredContactMethod = data.preferredContactMethod;
|
||||
}
|
||||
if (data.nationalityIso && !existingClient.nationalityIso) {
|
||||
updates.nationalityIso = data.nationalityIso;
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await tx.update(clients).set(updates).where(eq(clients.id, clientId));
|
||||
}
|
||||
} else {
|
||||
clientId = await createClientInTx(tx, portId, fullName, data);
|
||||
clientId = await createClientInTx(tx, portId, fullName, data, phoneE164, phoneCountry);
|
||||
}
|
||||
} else {
|
||||
clientId = await createClientInTx(tx, portId, fullName, data);
|
||||
clientId = await createClientInTx(tx, portId, fullName, data, phoneE164, phoneCountry);
|
||||
}
|
||||
|
||||
// 2. Optional: upsert company + add membership
|
||||
@@ -129,6 +145,8 @@ export async function POST(req: NextRequest) {
|
||||
legalName: data.company.legalName ?? null,
|
||||
taxId: data.company.taxId ?? null,
|
||||
incorporationCountry: data.company.incorporationCountry ?? null,
|
||||
incorporationCountryIso: data.company.incorporationCountryIso ?? null,
|
||||
incorporationSubdivisionIso: data.company.incorporationSubdivisionIso ?? null,
|
||||
status: 'active',
|
||||
})
|
||||
.returning();
|
||||
@@ -199,8 +217,10 @@ export async function POST(req: NextRequest) {
|
||||
streetAddress: data.address.street ?? null,
|
||||
city: data.address.city ?? null,
|
||||
stateProvince: data.address.stateProvince ?? null,
|
||||
subdivisionIso: data.address.subdivisionIso ?? null,
|
||||
postalCode: data.address.postalCode ?? null,
|
||||
country: data.address.country ?? null,
|
||||
countryIso: data.address.countryIso ?? null,
|
||||
isPrimary: true,
|
||||
});
|
||||
}
|
||||
@@ -279,7 +299,9 @@ async function createClientInTx(
|
||||
tx: Tx,
|
||||
portId: string,
|
||||
fullName: string,
|
||||
data: Pick<PublicInterestData, 'email' | 'phone' | 'preferredContactMethod'>,
|
||||
data: Pick<PublicInterestData, 'email' | 'phone' | 'preferredContactMethod' | 'nationalityIso'>,
|
||||
phoneE164: string | null,
|
||||
phoneCountry: CountryCode | null,
|
||||
): Promise<string> {
|
||||
const [newClient] = await tx
|
||||
.insert(clients)
|
||||
@@ -287,6 +309,7 @@ async function createClientInTx(
|
||||
portId,
|
||||
fullName,
|
||||
preferredContactMethod: data.preferredContactMethod,
|
||||
nationalityIso: data.nationalityIso ?? null,
|
||||
source: 'website',
|
||||
})
|
||||
.returning();
|
||||
@@ -303,6 +326,8 @@ async function createClientInTx(
|
||||
clientId,
|
||||
channel: 'phone',
|
||||
value: data.phone,
|
||||
valueE164: phoneE164,
|
||||
valueCountry: phoneCountry,
|
||||
isPrimary: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import {
|
||||
ALL_RANGES,
|
||||
getLeadSourceAttribution,
|
||||
@@ -18,7 +18,8 @@ const METRICS: Record<MetricBase, (portId: string, range: DateRange) => Promise<
|
||||
lead_source_attribution: getLeadSourceAttribution,
|
||||
};
|
||||
|
||||
export const GET = withAuth(async (req: NextRequest, ctx) => {
|
||||
export const GET = withAuth(
|
||||
withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => {
|
||||
const url = new URL(req.url);
|
||||
const metric = url.searchParams.get('metric') as MetricBase | null;
|
||||
const range = (url.searchParams.get('range') ?? '30d') as DateRange;
|
||||
@@ -32,4 +33,5 @@ export const GET = withAuth(async (req: NextRequest, ctx) => {
|
||||
|
||||
const data = await METRICS[metric](ctx.portId, range);
|
||||
return NextResponse.json({ metric, range, data });
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { withAuth, withPermission } 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) => {
|
||||
export const GET = withAuth(
|
||||
withPermission('berths', 'view', async (req, ctx) => {
|
||||
try {
|
||||
const options = await getBerthOptions(ctx.portId);
|
||||
return NextResponse.json({ data: options });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { listClientOptions } from '@/lib/services/clients.service';
|
||||
|
||||
export const GET = withAuth(async (req, ctx) => {
|
||||
export const GET = withAuth(
|
||||
withPermission('clients', 'view', async (req, ctx) => {
|
||||
try {
|
||||
const search = req.nextUrl.searchParams.get('search') ?? undefined;
|
||||
const data = await listClientOptions(ctx.portId, search);
|
||||
@@ -12,4 +13,5 @@ export const GET = withAuth(async (req, ctx) => {
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { getRecentActivity } from '@/lib/services/dashboard.service';
|
||||
|
||||
export const GET = withAuth(async (req: NextRequest, ctx) => {
|
||||
export const GET = withAuth(
|
||||
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
|
||||
const result = await getRecentActivity(ctx.portId);
|
||||
return NextResponse.json(result);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { getRevenueForecast } from '@/lib/services/dashboard.service';
|
||||
|
||||
export const GET = withAuth(async (req: NextRequest, ctx) => {
|
||||
export const GET = withAuth(
|
||||
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
|
||||
const result = await getRevenueForecast(ctx.portId);
|
||||
return NextResponse.json(result);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { getKpis } from '@/lib/services/dashboard.service';
|
||||
|
||||
export const GET = withAuth(async (req: NextRequest, ctx) => {
|
||||
export const GET = withAuth(
|
||||
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
|
||||
const result = await getKpis(ctx.portId);
|
||||
return NextResponse.json(result);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { getPipelineCounts } from '@/lib/services/dashboard.service';
|
||||
|
||||
export const GET = withAuth(async (req: NextRequest, ctx) => {
|
||||
export const GET = withAuth(
|
||||
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
|
||||
const result = await getPipelineCounts(ctx.portId);
|
||||
return NextResponse.json(result);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -84,18 +84,27 @@ export type NewAnalyticsSnapshot = typeof analyticsSnapshots.$inferInsert;
|
||||
/** Severity literal type for callers that want a typed enum. */
|
||||
export type AlertSeverity = 'info' | 'warning' | 'critical';
|
||||
|
||||
/** Rule IDs in the v1 catalog — keep in sync with `alert-rules.ts`. */
|
||||
/**
|
||||
* Rule IDs in the v1 catalog — keep in sync with `alert-rules.ts`.
|
||||
*
|
||||
* Two rules from the original spec (`document.expiring_soon`,
|
||||
* `audit.suspicious_login`) are deferred until their data sources land:
|
||||
* - `document.expiring_soon` needs a `documents.expires_at` column populated
|
||||
* from Documenso responses (currently expiry isn't tracked).
|
||||
* - `audit.suspicious_login` needs better-auth instrumentation that writes
|
||||
* `login.failed` audit rows (the auth layer currently doesn't).
|
||||
* Re-add the literal here + the evaluator in `alert-rules.ts` once their
|
||||
* dependencies ship.
|
||||
*/
|
||||
export const ALERT_RULES = [
|
||||
'reservation.no_agreement',
|
||||
'interest.stale',
|
||||
'document.expiring_soon',
|
||||
'document.signer_overdue',
|
||||
'berth.under_offer_stalled',
|
||||
'expense.duplicate',
|
||||
'expense.unscanned',
|
||||
'interest.high_value_silent',
|
||||
'eoi.unsigned_long',
|
||||
'audit.suspicious_login',
|
||||
] as const;
|
||||
|
||||
export type AlertRuleId = (typeof ALERT_RULES)[number];
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* 4. Add a unit test in tests/unit/services/alert-rules-evaluators.test.ts.
|
||||
*/
|
||||
|
||||
import { and, eq, isNull, isNotNull, lt, gt, gte, sql, inArray, or, desc } from 'drizzle-orm';
|
||||
import { and, eq, isNull, isNotNull, lt, gt, sql, inArray, or, desc } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
@@ -19,7 +19,6 @@ import { berthReservations } from '@/lib/db/schema/reservations';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import { documents, documentSigners } from '@/lib/db/schema/documents';
|
||||
import { expenses } from '@/lib/db/schema/financial';
|
||||
import { auditLogs } from '@/lib/db/schema/system';
|
||||
import { alerts as alertsTable } from '@/lib/db/schema/insights';
|
||||
import { ALERT_RULES, type AlertRuleId } from '@/lib/db/schema/insights';
|
||||
|
||||
@@ -108,16 +107,6 @@ async function interestStale(portId: string): Promise<AlertCandidate[]> {
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── document.expiring_soon ───────────────────────────────────────────────────
|
||||
// In-flight signing documents whose expiry is within 7 days.
|
||||
|
||||
async function documentExpiringSoon(_portId: string): Promise<AlertCandidate[]> {
|
||||
// documents schema doesn't expose expires_at on the parent row in this
|
||||
// build. Until the column lands, fall back to no-op so the rule slot
|
||||
// is registered but doesn't fire.
|
||||
return [];
|
||||
}
|
||||
|
||||
// ─── document.signer_overdue ──────────────────────────────────────────────────
|
||||
// Pending signer for >14d, last reminder >7d ago (or never).
|
||||
|
||||
@@ -319,49 +308,15 @@ async function eoiUnsignedLong(portId: string): Promise<AlertCandidate[]> {
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── audit.suspicious_login ───────────────────────────────────────────────────
|
||||
// >3 failed logins from same IP in the past hour. Depends on the auth layer
|
||||
// recording rows with action='login.failed' (TODO: instrument better-auth
|
||||
// hooks to record these — until that lands, this evaluator returns [] and
|
||||
// the rule slot stays inert).
|
||||
|
||||
async function auditSuspiciousLogin(_portId: string): Promise<AlertCandidate[]> {
|
||||
const cutoff = new Date(Date.now() - 60 * 60 * 1000);
|
||||
const rows = await db
|
||||
.select({
|
||||
ipAddress: auditLogs.ipAddress,
|
||||
attempts: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(auditLogs)
|
||||
.where(and(eq(auditLogs.action, 'login.failed'), gte(auditLogs.createdAt, cutoff)))
|
||||
.groupBy(auditLogs.ipAddress)
|
||||
.having(sql`count(*) > 3`);
|
||||
|
||||
return rows
|
||||
.filter((r) => r.ipAddress)
|
||||
.map((r) => ({
|
||||
ruleId: 'audit.suspicious_login' as const,
|
||||
severity: 'critical' as const,
|
||||
title: `Repeated failed logins`,
|
||||
body: `${r.attempts} failed attempts from ${r.ipAddress} in the last hour.`,
|
||||
link: `/[port]/admin/audit?ip=${encodeURIComponent(r.ipAddress!)}`,
|
||||
entityType: 'audit',
|
||||
entityId: r.ipAddress!,
|
||||
metadata: { attempts: r.attempts },
|
||||
}));
|
||||
}
|
||||
|
||||
export const RULE_REGISTRY: Record<AlertRuleId, RuleEvaluator> = {
|
||||
'reservation.no_agreement': reservationNoAgreement,
|
||||
'interest.stale': interestStale,
|
||||
'document.expiring_soon': documentExpiringSoon,
|
||||
'document.signer_overdue': documentSignerOverdue,
|
||||
'berth.under_offer_stalled': berthUnderOfferStalled,
|
||||
'expense.duplicate': expenseDuplicate,
|
||||
'expense.unscanned': expenseUnscanned,
|
||||
'interest.high_value_silent': interestHighValueSilent,
|
||||
'eoi.unsigned_long': eoiUnsignedLong,
|
||||
'audit.suspicious_login': auditSuspiciousLogin,
|
||||
};
|
||||
|
||||
export function listRuleIds(): readonly AlertRuleId[] {
|
||||
|
||||
@@ -578,7 +578,9 @@ export async function findDuplicates(portId: string, fullName: string) {
|
||||
// ─── Options (for comboboxes) ─────────────────────────────────────────────────
|
||||
|
||||
export async function listClientOptions(portId: string, search?: string) {
|
||||
const conditions = [eq(clients.portId, portId)];
|
||||
// Pickers only surface active rows. Archived clients are still resolvable
|
||||
// by id (e.g. history views) but should not appear in dropdowns.
|
||||
const conditions = [eq(clients.portId, portId), isNull(clients.archivedAt)];
|
||||
if (search) {
|
||||
conditions.push(ilike(clients.fullName, `%${search}%`));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq, ilike, inArray, or, sql } from 'drizzle-orm';
|
||||
import { and, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm';
|
||||
import { db } from '@/lib/db';
|
||||
import { yachts, yachtOwnershipHistory, yachtTags, clients } from '@/lib/db/schema';
|
||||
import type { Yacht } from '@/lib/db/schema/yachts';
|
||||
@@ -355,11 +355,14 @@ export async function listYachtsForOwner(
|
||||
ownerType: 'client' | 'company',
|
||||
ownerId: string,
|
||||
) {
|
||||
// Owner-detail tabs only surface active yachts. Archived ones live in the
|
||||
// ownership history view and are reachable by id, not via this lister.
|
||||
return await db.query.yachts.findMany({
|
||||
where: and(
|
||||
eq(yachts.portId, portId),
|
||||
eq(yachts.currentOwnerType, ownerType),
|
||||
eq(yachts.currentOwnerId, ownerId),
|
||||
isNull(yachts.archivedAt),
|
||||
),
|
||||
orderBy: (t, { desc }) => [desc(t.updatedAt)],
|
||||
});
|
||||
|
||||
@@ -2,6 +2,11 @@ import { z } from 'zod';
|
||||
|
||||
import { baseListQuerySchema } from '@/lib/api/route-helpers';
|
||||
import { PIPELINE_STAGES, LEAD_CATEGORIES } from '@/lib/constants';
|
||||
import {
|
||||
optionalCountryIsoSchema,
|
||||
optionalPhoneE164Schema,
|
||||
optionalSubdivisionIsoSchema,
|
||||
} from '@/lib/validators/i18n';
|
||||
|
||||
// ─── Create ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -69,9 +74,15 @@ export const generateRecommendationsSchema = z.object({
|
||||
const addressSchema = z.object({
|
||||
street: z.string().max(500).optional(),
|
||||
city: z.string().max(200).optional(),
|
||||
/** Legacy free-text. New writes use `subdivisionIso`. */
|
||||
stateProvince: z.string().max(200).optional(),
|
||||
/** ISO 3166-2 subdivision code (e.g. 'PL-MZ'). */
|
||||
subdivisionIso: optionalSubdivisionIsoSchema.optional(),
|
||||
postalCode: z.string().max(50).optional(),
|
||||
/** Legacy free-text. New writes use `countryIso`. */
|
||||
country: z.string().max(100).optional(),
|
||||
/** ISO-3166-1 alpha-2 country code. */
|
||||
countryIso: optionalCountryIsoSchema.optional(),
|
||||
});
|
||||
|
||||
// Nested yacht block. Public submissions must now include yacht data because the
|
||||
@@ -94,7 +105,12 @@ const publicCompanySchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
legalName: z.string().max(200).optional(),
|
||||
taxId: z.string().max(100).optional(),
|
||||
/** Legacy free-text. New website builds should send `incorporationCountryIso`. */
|
||||
incorporationCountry: z.string().max(100).optional(),
|
||||
/** ISO-3166-1 alpha-2 country of incorporation. */
|
||||
incorporationCountryIso: optionalCountryIsoSchema.optional(),
|
||||
/** ISO 3166-2 state/province of incorporation. */
|
||||
incorporationSubdivisionIso: optionalSubdivisionIsoSchema.optional(),
|
||||
role: z
|
||||
.enum([
|
||||
'director',
|
||||
@@ -119,6 +135,12 @@ export const publicInterestSchema = z
|
||||
fullName: z.string().min(1).max(200).optional(),
|
||||
email: z.string().email(),
|
||||
phone: z.string().min(1),
|
||||
/** Pre-normalized E.164 form, optional for backwards compat. */
|
||||
phoneE164: optionalPhoneE164Schema.optional(),
|
||||
/** ISO-3166-1 alpha-2 country the phone was parsed against. */
|
||||
phoneCountry: optionalCountryIsoSchema.optional(),
|
||||
/** ISO-3166-1 alpha-2 nationality. */
|
||||
nationalityIso: optionalCountryIsoSchema.optional(),
|
||||
preferredContactMethod: z.enum(['email', 'phone', 'sms']).optional(),
|
||||
mooringNumber: z.string().max(50).optional(),
|
||||
// NEW: required structured yacht block. Public submissions after the
|
||||
|
||||
@@ -11,7 +11,16 @@
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
|
||||
import { makeAuditMeta, makeCreateClientInput, makeCreateInterestInput } from '../helpers/factories';
|
||||
import {
|
||||
makeAuditMeta,
|
||||
makeCreateClientInput,
|
||||
makeCreateInterestInput,
|
||||
} from '../helpers/factories';
|
||||
|
||||
vi.mock('@/lib/socket/server', () => ({ emitToRoom: vi.fn() }));
|
||||
vi.mock('@/lib/queue', () => ({
|
||||
getQueue: () => ({ add: vi.fn().mockResolvedValue(undefined) }),
|
||||
}));
|
||||
|
||||
const TEST_DB_URL =
|
||||
process.env.TEST_DATABASE_URL || 'postgresql://test:test@localhost:5433/portnimara_test';
|
||||
@@ -93,11 +102,6 @@ async function getAuditEntries(
|
||||
describe('CRUD Audit — Clients', () => {
|
||||
let portId: string;
|
||||
|
||||
vi.mock('@/lib/socket/server', () => ({ emitToRoom: vi.fn() }));
|
||||
vi.mock('@/lib/queue', () => ({
|
||||
getQueue: () => ({ add: vi.fn().mockResolvedValue(undefined) }),
|
||||
}));
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!dbAvailable) return;
|
||||
portId = await seedPort();
|
||||
@@ -112,7 +116,11 @@ describe('CRUD Audit — Clients', () => {
|
||||
const { createClient } = await import('@/lib/services/clients.service');
|
||||
const meta = makeAuditMeta({ portId });
|
||||
|
||||
const client = await createClient(portId, makeCreateClientInput({ fullName: 'Audit Create Client' }), meta);
|
||||
const client = await createClient(
|
||||
portId,
|
||||
makeCreateClientInput({ fullName: 'Audit Create Client' }),
|
||||
meta,
|
||||
);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
@@ -130,7 +138,11 @@ describe('CRUD Audit — Clients', () => {
|
||||
const { createClient, updateClient } = await import('@/lib/services/clients.service');
|
||||
const meta = makeAuditMeta({ portId });
|
||||
|
||||
const client = await createClient(portId, makeCreateClientInput({ fullName: 'Before Update' }), meta);
|
||||
const client = await createClient(
|
||||
portId,
|
||||
makeCreateClientInput({ fullName: 'Before Update' }),
|
||||
meta,
|
||||
);
|
||||
|
||||
await updateClient(client.id, portId, { fullName: 'After Update' }, meta);
|
||||
|
||||
@@ -149,7 +161,11 @@ describe('CRUD Audit — Clients', () => {
|
||||
const { createClient, archiveClient } = await import('@/lib/services/clients.service');
|
||||
const meta = makeAuditMeta({ portId });
|
||||
|
||||
const client = await createClient(portId, makeCreateClientInput({ fullName: 'Audit Archive Client' }), meta);
|
||||
const client = await createClient(
|
||||
portId,
|
||||
makeCreateClientInput({ fullName: 'Audit Archive Client' }),
|
||||
meta,
|
||||
);
|
||||
|
||||
await archiveClient(client.id, portId, meta);
|
||||
|
||||
@@ -161,10 +177,15 @@ describe('CRUD Audit — Clients', () => {
|
||||
});
|
||||
|
||||
itDb('restore generates an audit log entry with action=restore', async () => {
|
||||
const { createClient, archiveClient, restoreClient } = await import('@/lib/services/clients.service');
|
||||
const { createClient, archiveClient, restoreClient } =
|
||||
await import('@/lib/services/clients.service');
|
||||
const meta = makeAuditMeta({ portId });
|
||||
|
||||
const client = await createClient(portId, makeCreateClientInput({ fullName: 'Audit Restore Client' }), meta);
|
||||
const client = await createClient(
|
||||
portId,
|
||||
makeCreateClientInput({ fullName: 'Audit Restore Client' }),
|
||||
meta,
|
||||
);
|
||||
|
||||
await archiveClient(client.id, portId, meta);
|
||||
await restoreClient(client.id, portId, meta);
|
||||
@@ -183,17 +204,16 @@ describe('CRUD Audit — Interests', () => {
|
||||
let portId: string;
|
||||
let clientId: string;
|
||||
|
||||
vi.mock('@/lib/socket/server', () => ({ emitToRoom: vi.fn() }));
|
||||
vi.mock('@/lib/queue', () => ({
|
||||
getQueue: () => ({ add: vi.fn().mockResolvedValue(undefined) }),
|
||||
}));
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!dbAvailable) return;
|
||||
portId = await seedPort();
|
||||
|
||||
const { createClient } = await import('@/lib/services/clients.service');
|
||||
const client = await createClient(portId, makeCreateClientInput({ fullName: 'Interest Audit Client' }), makeAuditMeta({ portId }));
|
||||
const client = await createClient(
|
||||
portId,
|
||||
makeCreateClientInput({ fullName: 'Interest Audit Client' }),
|
||||
makeAuditMeta({ portId }),
|
||||
);
|
||||
clientId = client.id;
|
||||
});
|
||||
|
||||
@@ -223,7 +243,11 @@ describe('CRUD Audit — Interests', () => {
|
||||
const { createInterest, updateInterest } = await import('@/lib/services/interests.service');
|
||||
const meta = makeAuditMeta({ portId });
|
||||
|
||||
const interest = await createInterest(portId, { ...makeCreateInterestInput({ clientId }), notes: 'initial' }, meta);
|
||||
const interest = await createInterest(
|
||||
portId,
|
||||
{ ...makeCreateInterestInput({ clientId }), notes: 'initial' },
|
||||
meta,
|
||||
);
|
||||
|
||||
await updateInterest(interest.id, portId, { notes: 'updated notes' }, meta);
|
||||
|
||||
@@ -249,7 +273,8 @@ describe('CRUD Audit — Interests', () => {
|
||||
});
|
||||
|
||||
itDb('restore generates audit log with action=restore', async () => {
|
||||
const { createInterest, archiveInterest, restoreInterest } = await import('@/lib/services/interests.service');
|
||||
const { createInterest, archiveInterest, restoreInterest } =
|
||||
await import('@/lib/services/interests.service');
|
||||
const meta = makeAuditMeta({ portId });
|
||||
|
||||
const interest = await createInterest(portId, makeCreateInterestInput({ clientId }), meta);
|
||||
@@ -270,11 +295,6 @@ describe('CRUD Audit — Berths', () => {
|
||||
let portId: string;
|
||||
let berthId: string;
|
||||
|
||||
vi.mock('@/lib/socket/server', () => ({ emitToRoom: vi.fn() }));
|
||||
vi.mock('@/lib/queue', () => ({
|
||||
getQueue: () => ({ add: vi.fn().mockResolvedValue(undefined) }),
|
||||
}));
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!dbAvailable) return;
|
||||
portId = await seedPort();
|
||||
@@ -313,8 +333,8 @@ describe('CRUD Audit — Berths', () => {
|
||||
const wrongPortId = crypto.randomUUID();
|
||||
const meta = makeAuditMeta({ portId: wrongPortId });
|
||||
|
||||
await expect(
|
||||
updateBerth(berthId, wrongPortId, { area: 'Should fail' }, meta),
|
||||
).rejects.toThrow(NotFoundError);
|
||||
await expect(updateBerth(berthId, wrongPortId, { area: 'Should fail' }, meta)).rejects.toThrow(
|
||||
NotFoundError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,10 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
|
||||
import { makeAuditMeta } from '../helpers/factories';
|
||||
|
||||
vi.mock('@/lib/audit', () => ({
|
||||
createAuditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const TEST_DB_URL =
|
||||
process.env.TEST_DATABASE_URL || 'postgresql://test:test@localhost:5433/portnimara_test';
|
||||
|
||||
@@ -66,10 +70,6 @@ describe('Custom Fields — Definitions', () => {
|
||||
let portId: string;
|
||||
const userId = crypto.randomUUID();
|
||||
|
||||
vi.mock('@/lib/audit', () => ({
|
||||
createAuditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!dbAvailable) return;
|
||||
portId = await seedPort();
|
||||
@@ -141,9 +141,8 @@ describe('Custom Fields — Definitions', () => {
|
||||
});
|
||||
|
||||
itDb('updateDefinition with fieldType property throws ValidationError', async () => {
|
||||
const { createDefinition, updateDefinition } = await import(
|
||||
'@/lib/services/custom-fields.service'
|
||||
);
|
||||
const { createDefinition, updateDefinition } =
|
||||
await import('@/lib/services/custom-fields.service');
|
||||
const { ValidationError } = await import('@/lib/errors');
|
||||
const meta = makeAuditMeta({ portId, userId });
|
||||
|
||||
@@ -161,16 +160,21 @@ describe('Custom Fields — Definitions', () => {
|
||||
meta,
|
||||
);
|
||||
|
||||
// Cast to any to bypass TS — the service should guard against this at runtime
|
||||
// Cast bypasses TS — the service should guard against this at runtime.
|
||||
await expect(
|
||||
updateDefinition(portId, def.id, userId, { fieldType: 'number' } as any, meta),
|
||||
updateDefinition(
|
||||
portId,
|
||||
def.id,
|
||||
userId,
|
||||
{ fieldType: 'number' } as unknown as Parameters<typeof updateDefinition>[3],
|
||||
meta,
|
||||
),
|
||||
).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
itDb('updateDefinition can change fieldLabel without error', async () => {
|
||||
const { createDefinition, updateDefinition } = await import(
|
||||
'@/lib/services/custom-fields.service'
|
||||
);
|
||||
const { createDefinition, updateDefinition } =
|
||||
await import('@/lib/services/custom-fields.service');
|
||||
const meta = makeAuditMeta({ portId, userId });
|
||||
|
||||
const def = await createDefinition(
|
||||
@@ -187,7 +191,13 @@ describe('Custom Fields — Definitions', () => {
|
||||
meta,
|
||||
);
|
||||
|
||||
const updated = await updateDefinition(portId, def.id, userId, { fieldLabel: 'Special Notes' }, meta);
|
||||
const updated = await updateDefinition(
|
||||
portId,
|
||||
def.id,
|
||||
userId,
|
||||
{ fieldLabel: 'Special Notes' },
|
||||
meta,
|
||||
);
|
||||
expect(updated.fieldLabel).toBe('Special Notes');
|
||||
expect(updated.fieldType).toBe('text');
|
||||
});
|
||||
@@ -200,10 +210,6 @@ describe('Custom Fields — Values', () => {
|
||||
const userId = crypto.randomUUID();
|
||||
const entityId = crypto.randomUUID();
|
||||
|
||||
vi.mock('@/lib/audit', () => ({
|
||||
createAuditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!dbAvailable) return;
|
||||
portId = await seedPort();
|
||||
@@ -215,9 +221,8 @@ describe('Custom Fields — Values', () => {
|
||||
});
|
||||
|
||||
itDb('setValues stores a text value and getValues returns it with definition', async () => {
|
||||
const { createDefinition, setValues, getValues } = await import(
|
||||
'@/lib/services/custom-fields.service'
|
||||
);
|
||||
const { createDefinition, setValues, getValues } =
|
||||
await import('@/lib/services/custom-fields.service');
|
||||
const meta = makeAuditMeta({ portId, userId });
|
||||
|
||||
const def = await createDefinition(
|
||||
@@ -270,9 +275,8 @@ describe('Custom Fields — Values', () => {
|
||||
});
|
||||
|
||||
itDb('deleteDefinition cascades to remove associated values', async () => {
|
||||
const { createDefinition, setValues, deleteDefinition, getValues } = await import(
|
||||
'@/lib/services/custom-fields.service'
|
||||
);
|
||||
const { createDefinition, setValues, deleteDefinition, getValues } =
|
||||
await import('@/lib/services/custom-fields.service');
|
||||
const meta = makeAuditMeta({ portId, userId });
|
||||
|
||||
const cascadeEntityId = crypto.randomUUID();
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
|
||||
// Socket and queue mocked — these are tested in isolation here.
|
||||
vi.mock('@/lib/socket/server', () => ({ emitToRoom: vi.fn() }));
|
||||
vi.mock('@/lib/queue', () => ({
|
||||
getQueue: () => ({ add: vi.fn().mockResolvedValue(undefined) }),
|
||||
}));
|
||||
|
||||
const TEST_DB_URL =
|
||||
process.env.TEST_DATABASE_URL || 'postgresql://test:test@localhost:5433/portnimara_test';
|
||||
|
||||
@@ -83,12 +89,6 @@ describe('Notification Lifecycle', () => {
|
||||
let portId: string;
|
||||
let userId: string;
|
||||
|
||||
// Mock socket and queue — these are tested in isolation here
|
||||
vi.mock('@/lib/socket/server', () => ({ emitToRoom: vi.fn() }));
|
||||
vi.mock('@/lib/queue', () => ({
|
||||
getQueue: () => ({ add: vi.fn().mockResolvedValue(undefined) }),
|
||||
}));
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!dbAvailable) return;
|
||||
({ portId, userId } = await seedPortAndUser());
|
||||
@@ -214,9 +214,8 @@ describe('Notification Lifecycle', () => {
|
||||
});
|
||||
|
||||
itDb('markAllRead sets all unread notifications for the user to read', async () => {
|
||||
const { createNotification, markAllRead, getUnreadCount } = await import(
|
||||
'@/lib/services/notifications.service'
|
||||
);
|
||||
const { createNotification, markAllRead, getUnreadCount } =
|
||||
await import('@/lib/services/notifications.service');
|
||||
|
||||
await createNotification({ portId, userId, type: 'system_alert', title: 'Unread 1' });
|
||||
await createNotification({ portId, userId, type: 'system_alert', title: 'Unread 2' });
|
||||
@@ -231,9 +230,8 @@ describe('Notification Lifecycle', () => {
|
||||
});
|
||||
|
||||
itDb('getUnreadCount returns accurate count', async () => {
|
||||
const { createNotification, getUnreadCount, markAllRead } = await import(
|
||||
'@/lib/services/notifications.service'
|
||||
);
|
||||
const { createNotification, getUnreadCount, markAllRead } =
|
||||
await import('@/lib/services/notifications.service');
|
||||
|
||||
await markAllRead(userId, portId);
|
||||
|
||||
|
||||
@@ -13,7 +13,17 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
|
||||
import { PIPELINE_STAGES } from '@/lib/constants';
|
||||
import { makeAuditMeta, makeCreateClientInput, makeCreateInterestInput } from '../helpers/factories';
|
||||
import {
|
||||
makeAuditMeta,
|
||||
makeCreateClientInput,
|
||||
makeCreateInterestInput,
|
||||
} from '../helpers/factories';
|
||||
|
||||
// External side-effects mocked so the test stays self-contained.
|
||||
vi.mock('@/lib/socket/server', () => ({ emitToRoom: vi.fn() }));
|
||||
vi.mock('@/lib/queue', () => ({
|
||||
getQueue: () => ({ add: vi.fn().mockResolvedValue(undefined) }),
|
||||
}));
|
||||
|
||||
const TEST_DB_URL =
|
||||
process.env.TEST_DATABASE_URL || 'postgresql://test:test@localhost:5433/portnimara_test';
|
||||
@@ -62,7 +72,10 @@ async function cleanupPort(portId: string): Promise<void> {
|
||||
await sql.end();
|
||||
}
|
||||
|
||||
async function getLatestAuditLog(portId: string, entityId: string): Promise<Record<string, unknown> | null> {
|
||||
async function getLatestAuditLog(
|
||||
portId: string,
|
||||
entityId: string,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
const postgres = (await import('postgres')).default;
|
||||
const sql = postgres(TEST_DB_URL, { max: 1 });
|
||||
const rows = await sql<Record<string, unknown>[]>`
|
||||
@@ -81,12 +94,6 @@ describe('Pipeline Transitions', () => {
|
||||
let portId: string;
|
||||
let interestId: string;
|
||||
|
||||
// Mock external side-effects so tests are self-contained
|
||||
vi.mock('@/lib/socket/server', () => ({ emitToRoom: vi.fn() }));
|
||||
vi.mock('@/lib/queue', () => ({
|
||||
getQueue: () => ({ add: vi.fn().mockResolvedValue(undefined) }),
|
||||
}));
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!dbAvailable) return;
|
||||
|
||||
@@ -95,10 +102,18 @@ describe('Pipeline Transitions', () => {
|
||||
const { createClient } = await import('@/lib/services/clients.service');
|
||||
const meta = makeAuditMeta({ portId });
|
||||
|
||||
const client = await createClient(portId, makeCreateClientInput({ fullName: 'Pipeline Test Client' }), meta);
|
||||
const client = await createClient(
|
||||
portId,
|
||||
makeCreateClientInput({ fullName: 'Pipeline Test Client' }),
|
||||
meta,
|
||||
);
|
||||
|
||||
const { createInterest } = await import('@/lib/services/interests.service');
|
||||
const interest = await createInterest(portId, makeCreateInterestInput({ clientId: client.id }), meta);
|
||||
const interest = await createInterest(
|
||||
portId,
|
||||
makeCreateInterestInput({ clientId: client.id }),
|
||||
meta,
|
||||
);
|
||||
interestId = interest.id;
|
||||
});
|
||||
|
||||
@@ -108,9 +123,8 @@ describe('Pipeline Transitions', () => {
|
||||
});
|
||||
|
||||
itDb('advances through all 8 pipeline stages sequentially', async () => {
|
||||
const { changeInterestStage, getInterestById } = await import(
|
||||
'@/lib/services/interests.service'
|
||||
);
|
||||
const { changeInterestStage, getInterestById } =
|
||||
await import('@/lib/services/interests.service');
|
||||
const meta = makeAuditMeta({ portId });
|
||||
|
||||
for (const stage of PIPELINE_STAGES) {
|
||||
@@ -140,9 +154,8 @@ describe('Pipeline Transitions', () => {
|
||||
});
|
||||
|
||||
itDb('backward transition: completed → open is permitted', async () => {
|
||||
const { changeInterestStage, getInterestById } = await import(
|
||||
'@/lib/services/interests.service'
|
||||
);
|
||||
const { changeInterestStage, getInterestById } =
|
||||
await import('@/lib/services/interests.service');
|
||||
const meta = makeAuditMeta({ portId });
|
||||
|
||||
await changeInterestStage(interestId, portId, { pipelineStage: 'completed' }, meta);
|
||||
@@ -153,9 +166,8 @@ describe('Pipeline Transitions', () => {
|
||||
});
|
||||
|
||||
itDb('BR-133: advancing to signed_eoi_nda auto-populates dateEoiSigned', async () => {
|
||||
const { changeInterestStage, getInterestById } = await import(
|
||||
'@/lib/services/interests.service'
|
||||
);
|
||||
const { changeInterestStage, getInterestById } =
|
||||
await import('@/lib/services/interests.service');
|
||||
const meta = makeAuditMeta({ portId });
|
||||
|
||||
await changeInterestStage(interestId, portId, { pipelineStage: 'signed_eoi_nda' }, meta);
|
||||
@@ -165,9 +177,8 @@ describe('Pipeline Transitions', () => {
|
||||
});
|
||||
|
||||
itDb('BR-133: advancing to contract auto-populates dateContractSigned', async () => {
|
||||
const { changeInterestStage, getInterestById } = await import(
|
||||
'@/lib/services/interests.service'
|
||||
);
|
||||
const { changeInterestStage, getInterestById } =
|
||||
await import('@/lib/services/interests.service');
|
||||
const meta = makeAuditMeta({ portId });
|
||||
|
||||
await changeInterestStage(interestId, portId, { pipelineStage: 'contract' }, meta);
|
||||
@@ -177,9 +188,8 @@ describe('Pipeline Transitions', () => {
|
||||
});
|
||||
|
||||
itDb('BR-133: advancing to deposit_10pct auto-populates dateDepositReceived', async () => {
|
||||
const { changeInterestStage, getInterestById } = await import(
|
||||
'@/lib/services/interests.service'
|
||||
);
|
||||
const { changeInterestStage, getInterestById } =
|
||||
await import('@/lib/services/interests.service');
|
||||
const meta = makeAuditMeta({ portId });
|
||||
|
||||
await changeInterestStage(interestId, portId, { pipelineStage: 'deposit_10pct' }, meta);
|
||||
|
||||
@@ -14,6 +14,27 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
|
||||
import { makeAuditMeta } from '../helpers/factories';
|
||||
|
||||
// vi.mock is hoisted to the top of the module — keep mocks there so vitest
|
||||
// doesn't warn about non-top-level calls. Use `vi.hoisted` for any mock that
|
||||
// references a value (mockQueueAdd) so it's evaluated before the mock factory
|
||||
// runs.
|
||||
const { mockQueueAdd } = vi.hoisted(() => ({
|
||||
mockQueueAdd: vi.fn().mockResolvedValue({ id: 'mock-job' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/queue', () => ({
|
||||
getQueue: () => ({ add: mockQueueAdd }),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/encryption', () => ({
|
||||
encrypt: (v: string) => `enc:${v}`,
|
||||
decrypt: (v: string) => v.replace(/^enc:/, ''),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/audit', () => ({
|
||||
createAuditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const TEST_DB_URL =
|
||||
process.env.TEST_DATABASE_URL || 'postgresql://test:test@localhost:5433/portnimara_test';
|
||||
|
||||
@@ -81,21 +102,6 @@ describe('Webhook Delivery', () => {
|
||||
let portId: string;
|
||||
let userId: string;
|
||||
|
||||
const mockQueueAdd = vi.fn().mockResolvedValue({ id: 'mock-job' });
|
||||
|
||||
vi.mock('@/lib/queue', () => ({
|
||||
getQueue: () => ({ add: mockQueueAdd }),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/encryption', () => ({
|
||||
encrypt: (v: string) => `enc:${v}`,
|
||||
decrypt: (v: string) => v.replace(/^enc:/, ''),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/audit', () => ({
|
||||
createAuditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!dbAvailable) return;
|
||||
({ portId, userId } = await seedPortAndUser());
|
||||
@@ -113,7 +119,12 @@ describe('Webhook Delivery', () => {
|
||||
const webhook = await createWebhook(
|
||||
portId,
|
||||
userId,
|
||||
{ name: 'Delivery Test Webhook', url: 'https://example.com/hooks', events: ['client.created'], isActive: true },
|
||||
{
|
||||
name: 'Delivery Test Webhook',
|
||||
url: 'https://example.com/hooks',
|
||||
events: ['client.created'],
|
||||
isActive: true,
|
||||
},
|
||||
meta,
|
||||
);
|
||||
|
||||
@@ -131,7 +142,12 @@ describe('Webhook Delivery', () => {
|
||||
const webhook = await createWebhook(
|
||||
portId,
|
||||
userId,
|
||||
{ name: 'Dispatch Test Hook', url: 'https://example.com/dispatch', events: ['client.created'], isActive: true },
|
||||
{
|
||||
name: 'Dispatch Test Hook',
|
||||
url: 'https://example.com/dispatch',
|
||||
events: ['client.created'],
|
||||
isActive: true,
|
||||
},
|
||||
meta,
|
||||
);
|
||||
|
||||
@@ -172,7 +188,12 @@ describe('Webhook Delivery', () => {
|
||||
const webhook = await createWebhook(
|
||||
portId,
|
||||
userId,
|
||||
{ name: 'Unmapped Hook', url: 'https://example.com/unmapped', events: ['client.created'], isActive: true },
|
||||
{
|
||||
name: 'Unmapped Hook',
|
||||
url: 'https://example.com/unmapped',
|
||||
events: ['client.created'],
|
||||
isActive: true,
|
||||
},
|
||||
meta,
|
||||
);
|
||||
|
||||
@@ -201,7 +222,12 @@ describe('Webhook Delivery', () => {
|
||||
const webhook = await createWebhook(
|
||||
portId,
|
||||
userId,
|
||||
{ name: 'Inactive Hook', url: 'https://example.com/inactive', events: ['client.created'], isActive: false },
|
||||
{
|
||||
name: 'Inactive Hook',
|
||||
url: 'https://example.com/inactive',
|
||||
events: ['client.created'],
|
||||
isActive: false,
|
||||
},
|
||||
meta,
|
||||
);
|
||||
|
||||
@@ -230,7 +256,12 @@ describe('Webhook Delivery', () => {
|
||||
await createWebhook(
|
||||
portId,
|
||||
userId,
|
||||
{ name: 'Queue Test Hook', url: 'https://example.com/queue', events: ['client.updated'], isActive: true },
|
||||
{
|
||||
name: 'Queue Test Hook',
|
||||
url: 'https://example.com/queue',
|
||||
events: ['client.updated'],
|
||||
isActive: true,
|
||||
},
|
||||
meta,
|
||||
);
|
||||
|
||||
|
||||
@@ -34,9 +34,12 @@ describe('AES-256-GCM — plaintext non-exposure', () => {
|
||||
|
||||
it('encrypted output does not contain plaintext even for short values', async () => {
|
||||
const { encrypt } = await import('@/lib/utils/encryption');
|
||||
const plaintext = 'ab';
|
||||
// Pick a 2-char plaintext using *non-hex* characters so the assertion can't
|
||||
// false-positive: random hex bytes routinely contain pairs like 'ab' or 'cd'
|
||||
// by chance (~1 in 256 byte positions). Using 'XY' (neither is a hex digit)
|
||||
// means a passing assertion actually proves the plaintext didn't leak.
|
||||
const plaintext = 'XY';
|
||||
const encrypted = encrypt(plaintext);
|
||||
// The JSON output contains hex-encoded bytes — plaintext chars must not appear raw
|
||||
expect(encrypted).not.toContain(plaintext);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user