Skip to content

Commit a633c09

Browse files
authored
feat: add diagnostic endpoint, CORS proxy, step logging for Scottish Water
Agent-Logs-Url: https://github.com/JonnyDawe/UK-Sewage-Map/sessions/271da0ab-4bc2-4a85-bbd2-5f6dcbb655ec
1 parent 81fa09c commit a633c09

3 files changed

Lines changed: 142 additions & 4 deletions

File tree

api/debug-scottish-water.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/**
2+
* Diagnostic endpoint for the Scottish Water API integration.
3+
*
4+
* Accessible at /api/debug-scottish-water — returns a structured JSON report of
5+
* every step in the fetch → parse → validate pipeline so failures can be identified
6+
* without needing access to a browser console.
7+
*/
8+
9+
import { z } from 'zod';
10+
11+
export const config = {
12+
runtime: 'edge',
13+
};
14+
15+
const SCOTTISH_WATER_API_URL =
16+
'https://api.scottishwater.co.uk/overflow-event-monitoring/v1/near-real-time';
17+
18+
// Mirror of the schema in src/utils/discharge/schemas.ts
19+
const emptyStringToNull = (val: unknown) => (val === '' ? null : val);
20+
21+
const scottishWaterApiResultSchema = z.object({
22+
ASSET_ID: z.string().max(256).nullable(),
23+
ASSET_NAME: z.string().max(256).nullable(),
24+
OVERFLOW_STATUS_ID: z.preprocess(emptyStringToNull, z.coerce.number().nullable()),
25+
RECEIVING_WATER: z.string().max(256).nullable(),
26+
OVERFLOW_START_DATETIME: z.string().nullable(),
27+
OVERFLOW_END_DATETIME: z.string().nullable(),
28+
DISCHARGE_OVERFLOW_LOCATION_LATITUDE: z.preprocess(
29+
emptyStringToNull,
30+
z.coerce.number().nullable(),
31+
),
32+
DISCHARGE_OVERFLOW_LOCATION_LONGITUDE: z.preprocess(
33+
emptyStringToNull,
34+
z.coerce.number().nullable(),
35+
),
36+
});
37+
38+
const scottishWaterApiResponseSchema = z.object({
39+
results: z.array(scottishWaterApiResultSchema),
40+
});
41+
42+
export default async function handler(): Promise<Response> {
43+
const report: Record<string, unknown> = {};
44+
45+
// ── Step 1: fetch ────────────────────────────────────────────────────────
46+
let rawText: string;
47+
try {
48+
const response = await fetch(SCOTTISH_WATER_API_URL);
49+
report['step1_fetch'] = {
50+
ok: response.ok,
51+
status: response.status,
52+
statusText: response.statusText,
53+
contentType: response.headers.get('content-type'),
54+
};
55+
56+
if (!response.ok) {
57+
return json({ ...report, conclusion: `FAILED at step 1: HTTP ${response.status}` });
58+
}
59+
60+
rawText = await response.text();
61+
report['step2_raw_text'] = {
62+
byteLength: rawText.length,
63+
preview: rawText.slice(0, 500),
64+
};
65+
} catch (err) {
66+
report['step1_fetch'] = {
67+
ok: false,
68+
error: String(err),
69+
hint: 'fetch() threw — likely a network or CORS error from the edge function',
70+
};
71+
return json({ ...report, conclusion: 'FAILED at step 1: fetch threw an exception' });
72+
}
73+
74+
// ── Step 2: JSON parse ───────────────────────────────────────────────────
75+
let parsed: unknown;
76+
try {
77+
parsed = JSON.parse(rawText);
78+
report['step3_json_parse'] = { ok: true };
79+
} catch (err) {
80+
report['step3_json_parse'] = { ok: false, error: String(err) };
81+
return json({ ...report, conclusion: 'FAILED at step 3: response is not valid JSON' });
82+
}
83+
84+
// ── Step 3: top-level shape ──────────────────────────────────────────────
85+
const topLevelKeys =
86+
parsed !== null && typeof parsed === 'object' ? Object.keys(parsed as object) : [];
87+
report['step4_top_level_keys'] = topLevelKeys;
88+
89+
// ── Step 4: schema validation ────────────────────────────────────────────
90+
const schemaResult = scottishWaterApiResponseSchema.safeParse(parsed);
91+
92+
if (!schemaResult.success) {
93+
report['step5_schema_validation'] = {
94+
ok: false,
95+
errorCount: schemaResult.error.issues.length,
96+
// Show up to 10 issues to keep the response readable
97+
issues: schemaResult.error.issues.slice(0, 10),
98+
};
99+
return json({ ...report, conclusion: 'FAILED at step 5: Zod schema validation failed' });
100+
}
101+
102+
const { results } = schemaResult.data;
103+
report['step5_schema_validation'] = { ok: true, totalRecords: results.length };
104+
105+
// ── Step 5: coordinate filter ────────────────────────────────────────────
106+
const withCoords = results.filter(
107+
(r) =>
108+
r.DISCHARGE_OVERFLOW_LOCATION_LATITUDE != null &&
109+
r.DISCHARGE_OVERFLOW_LOCATION_LONGITUDE != null,
110+
);
111+
const withoutCoords = results.length - withCoords.length;
112+
113+
report['step6_coordinate_filter'] = {
114+
recordsWithCoords: withCoords.length,
115+
recordsDropped: withoutCoords,
116+
};
117+
118+
if (withCoords.length === 0) {
119+
return json({ ...report, conclusion: 'FAILED at step 6: no records have valid coordinates' });
120+
}
121+
122+
// ── Step 6: sample parsed records ───────────────────────────────────────
123+
report['step7_sample_records'] = withCoords.slice(0, 3);
124+
125+
return json({ ...report, conclusion: 'SUCCESS: all steps passed' });
126+
}
127+
128+
function json(data: unknown): Response {
129+
return new Response(JSON.stringify(data, null, 2), {
130+
headers: { 'Content-Type': 'application/json' },
131+
});
132+
}

api/scottish-water.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ export default async function handler(): Promise<Response> {
1616
'Cache-Control': 'public, max-age=60, s-maxage=60',
1717
},
1818
});
19-
} catch {
19+
} catch (err) {
20+
console.error('[ScottishWater] Proxy fetch failed:', err);
2021
return new Response(JSON.stringify({ error: 'Failed to fetch data from Scottish Water API' }), {
2122
status: 500,
2223
headers: { 'Content-Type': 'application/json' },

src/components/Map/commands/AddDischargeSources/AddDischargeSourcesCommand.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,12 @@ export class AddDischargeSourcesCommand implements MapCommand {
146146
console.log(`[ScottishWater] Step 4: ${validated.results.length} records from API`);
147147

148148
const withCoords = validated.results.filter(
149-
(item) =>
149+
(
150+
item,
151+
): item is typeof item & {
152+
DISCHARGE_OVERFLOW_LOCATION_LATITUDE: number;
153+
DISCHARGE_OVERFLOW_LOCATION_LONGITUDE: number;
154+
} =>
150155
item.DISCHARGE_OVERFLOW_LOCATION_LATITUDE != null &&
151156
item.DISCHARGE_OVERFLOW_LOCATION_LONGITUDE != null,
152157
);
@@ -166,8 +171,8 @@ export class AddDischargeSourcesCommand implements MapCommand {
166171
(item, index) =>
167172
new Graphic({
168173
geometry: new Point({
169-
longitude: item.DISCHARGE_OVERFLOW_LOCATION_LONGITUDE!,
170-
latitude: item.DISCHARGE_OVERFLOW_LOCATION_LATITUDE!,
174+
longitude: item.DISCHARGE_OVERFLOW_LOCATION_LONGITUDE,
175+
latitude: item.DISCHARGE_OVERFLOW_LOCATION_LATITUDE,
171176
spatialReference: { wkid: 4326 },
172177
}),
173178
attributes: {

0 commit comments

Comments
 (0)