diff --git a/src/components/shared/notes-list.tsx b/src/components/shared/notes-list.tsx index 47fd5de2..5e934030 100644 --- a/src/components/shared/notes-list.tsx +++ b/src/components/shared/notes-list.tsx @@ -70,6 +70,20 @@ const AGGREGATABLE: ReadonlySet = new Set([ 'residential_clients', ]); +/** Maps the entityType discriminator to its REST path segment. The + * residential entities use slash-separated routes + * (`/api/v1/residential/clients/...`), so the raw underscore discriminator + * must NOT be interpolated into the URL — doing so 404'd every residential + * notes request and the UI silently showed "No notes yet" (audit H7). */ +const NOTES_API_PATH: Record = { + clients: 'clients', + interests: 'interests', + yachts: 'yachts', + companies: 'companies', + residential_clients: 'residential/clients', + residential_interests: 'residential/interests', +}; + const SOURCE_BADGE_CLASS: Record = { client: 'bg-violet-100 text-violet-900', interest: 'bg-blue-100 text-blue-900', @@ -189,7 +203,7 @@ export function NotesList({ }, []); const aggregateOn = !!aggregate && AGGREGATABLE.has(entityType); - const baseEndpoint = `/api/v1/${entityType}/${entityId}/notes`; + const baseEndpoint = `/api/v1/${NOTES_API_PATH[entityType]}/${entityId}/notes`; const listEndpoint = aggregateOn ? `${baseEndpoint}?aggregate=true` : baseEndpoint; const queryKey = [entityType, entityId, 'notes', aggregateOn ? 'aggregated' : 'own']; diff --git a/src/lib/queue/workers/webhooks.ts b/src/lib/queue/workers/webhooks.ts index bcd8f48a..87610dda 100644 --- a/src/lib/queue/workers/webhooks.ts +++ b/src/lib/queue/workers/webhooks.ts @@ -223,6 +223,11 @@ export const webhooksWorker = new Worker( const response = await fetch(webhook.url, { method: 'POST', + // SSRF guard (audit H1): never follow redirects. resolveAndCheckHost + // validated the configured host, but a 3xx Location could point at + // cloud-metadata / RFC1918 with no re-validation. With 'manual' the + // redirect is returned, not followed, and we refuse to read its body. + redirect: 'manual', headers: { 'Content-Type': 'application/json', 'User-Agent': 'PortNimara-Webhook/1.0', @@ -238,10 +243,19 @@ export const webhooksWorker = new Worker( clearTimeout(timeoutId); responseStatus = response.status; - // Read up to 1KB of response body - const rawBody = await response.text(); - responseBody = rawBody.slice(0, 1024); - success = response.status >= 200 && response.status < 300; + if (response.status >= 300 && response.status < 400) { + // Redirect not followed — treat as a delivery failure and do NOT + // read/expose the (un-validated) redirected response. + responseBody = `Blocked: redirect (${response.status} -> ${ + response.headers.get('location') ?? 'unknown' + }) not followed (SSRF guard)`; + success = false; + } else { + // Read up to 1KB of response body + const rawBody = await response.text(); + responseBody = rawBody.slice(0, 1024); + success = response.status >= 200 && response.status < 300; + } } catch (err) { // Network error or timeout logger.warn({ webhookId, deliveryId, err }, 'Webhook delivery network error'); diff --git a/src/lib/services/dashboard-report-data.service.ts b/src/lib/services/dashboard-report-data.service.ts index 90f6d784..89a68090 100644 --- a/src/lib/services/dashboard-report-data.service.ts +++ b/src/lib/services/dashboard-report-data.service.ts @@ -286,7 +286,7 @@ export async function resolveDashboardReportData( and( eq(auditLogs.portId, portId), eq(auditLogs.entityType, 'berth'), - sql`${auditLogs.newValue}->>'status' = 'Sold'`, + sql`${auditLogs.newValue}->>'status' = 'sold'`, gte(auditLogs.createdAt, windowFrom), lte(auditLogs.createdAt, windowTo), ), @@ -457,12 +457,7 @@ export async function resolveDashboardReportData( const [{ occCount = 0 } = {}] = await db .select({ occCount: count() }) .from(berths) - .where( - and( - eq(berths.portId, portId), - sql`${berths.status} IN ('Sold', 'under_offer', 'Under offer')`, - ), - ); + .where(and(eq(berths.portId, portId), sql`${berths.status} IN ('sold', 'under_offer')`)); const currentRate = Number(totalCount) > 0 ? (Number(occCount) / Number(totalCount)) * 100 : 0; const series: Array<{ date: string; rate: number }> = []; const dayMs = 86_400_000;