Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b47199c
docs: add consultations implementation guide
kouloumos Feb 15, 2026
3ac54b4
feat: add geocoding script for regulation addresses
kouloumos Feb 15, 2026
0098253
feat: add cooking oil regulation generation script
kouloumos Feb 15, 2026
c4bf0c9
feat: add admin page for managing consultations
kouloumos Feb 15, 2026
a126471
fix: resolve relative URLs when fetching regulation JSON server-side
kouloumos Feb 15, 2026
b89bd7f
docs: add scripts and tooling section to consultations guide
kouloumos Feb 15, 2026
dc407b2
fix: scroll to top when navigating to map view via references
kouloumos Feb 15, 2026
1b936ff
feat: add "apply searched location" button to geo-editor
kouloumos Feb 15, 2026
734d088
feat: zoom to geometry on click and hash navigation in map view
kouloumos Feb 15, 2026
f9ba9dd
feat: add S3 upload and inline URL editing to admin consultations
kouloumos Feb 15, 2026
ade8f84
feat: add community picker with address search and welcome dialog to …
kouloumos Feb 16, 2026
b3a142d
fix: use verified domain for consultation emails and show recipients …
kouloumos Feb 16, 2026
3cee77e
fix: make .next/server/app writable in preview deployments for ISR
kouloumos Feb 16, 2026
752d0b2
feat: render consultation panels as bottom sheet drawers on mobile
kouloumos Feb 16, 2026
6c967b5
refactor: extract getSafeHtmlContent to shared utility
kouloumos Feb 25, 2026
8ab5427
refactor: extract createCircleBuffer to shared geo utility
kouloumos Feb 25, 2026
de2c24f
refactor: deduplicate CommentsOverviewSheet Drawer/Sheet branches
kouloumos Feb 25, 2026
2740688
refactor: move admin consultation queries to db layer and use server …
kouloumos Feb 25, 2026
87e26f2
refactor: consolidate geo utilities into src/lib/geo.ts
kouloumos Feb 25, 2026
5722304
test: add unit tests for geo utilities
kouloumos Feb 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
292 changes: 292 additions & 0 deletions docs/guides/consultations.md

Large diffs are not rendered by default.

24 changes: 22 additions & 2 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -1295,12 +1295,32 @@ EOF
fi
done

# Symlink .next contents except cache
# Symlink .next contents except cache and server/app.
# server/app must be writable so Next.js ISR can update
# pre-rendered pages (otherwise build-time data is served forever).
mkdir -p "$WORK_DIR/.next"
for item in "$APP_DIR/.next"/*; do
name="$(basename "$item")"
[ "$name" = "cache" ] && continue
ln -sfn "$item" "$WORK_DIR/.next/$name"
if [ "$name" = "server" ]; then
# Remove old symlink from previous script version (upgrade path)
[ -L "$WORK_DIR/.next/server" ] && rm -f "$WORK_DIR/.next/server"
mkdir -p "$WORK_DIR/.next/server"
for sitem in "$APP_DIR/.next/server"/*; do
sname="$(basename "$sitem")"
if [ "$sname" = "app" ]; then
# Remove stale copy from previous deployment, then copy fresh.
# Must chmod after cp since nix store files are read-only.
rm -rf "$WORK_DIR/.next/server/app"
cp -r "$sitem" "$WORK_DIR/.next/server/app"
chmod -R u+w "$WORK_DIR/.next/server/app"
else
ln -sfn "$sitem" "$WORK_DIR/.next/server/$sname"
fi
done
else
ln -sfn "$item" "$WORK_DIR/.next/$name"
fi
done

cd "$WORK_DIR"
Expand Down
441 changes: 441 additions & 0 deletions scripts/generate-cooking-oil-regulation.ts

Large diffs are not rendered by default.

321 changes: 321 additions & 0 deletions scripts/geocode-regulation-addresses.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,321 @@
#!/usr/bin/env tsx

/**
* Geocode addresses in a regulation JSON file using Google Geocoding API.
*
* Finds all point geometries with a textualDefinition but no geojson coordinates,
* geocodes each address scoped to Athens, and fills in the geojson field.
*
* Usage:
* npx tsx scripts/geocode-regulation-addresses.ts <path_to_regulation.json>
*
* Options:
* --dry-run Preview what would be geocoded without making API calls
* --force Re-geocode even if geojson already exists
* --delay=N Delay between API calls in ms (default: 200)
*/

import fs from 'fs';
import path from 'path';
import axios from 'axios';
import dotenv from 'dotenv';

dotenv.config();

const GOOGLE_API_KEY = process.env.GOOGLE_API_KEY;

// Athens center coordinates for biasing geocoding results
const ATHENS_CENTER = { lat: 37.9838, lng: 23.7275 };
// Bounding box for Athens municipality (SW corner, NE corner)
const ATHENS_BOUNDS = {
south: 37.94,
west: 23.68,
north: 38.02,
east: 23.79,
};

interface GeoJSONPoint {
type: 'Point';
coordinates: [number, number]; // [lng, lat]
}

interface Geometry {
type: string;
name: string;
id: string;
description?: string;
textualDefinition?: string;
geojson: GeoJSONPoint | null;
}

interface GeoSet {
type: 'geoset';
id: string;
name: string;
geometries: Geometry[];
}

interface RegulationData {
regulation: Array<GeoSet | { type: string }>;
[key: string]: unknown;
}

interface GeocodingResult {
geometryId: string;
address: string;
status: 'success' | 'zero_results' | 'error';
coordinates?: [number, number];
formattedAddress?: string;
error?: string;
}

async function geocodeAddress(
address: string,
geosetName: string,
): Promise<{
coordinates: [number, number];
formattedAddress: string;
} | null> {
// Build a search query scoped to Athens
// Append "Αθήνα" (Athens) to help the geocoder scope results
const query = `${address}, ${geosetName}, Αθήνα, Ελλάδα`;

const response = await axios.get(
'https://maps.googleapis.com/maps/api/geocode/json',
{
params: {
address: query,
key: GOOGLE_API_KEY,
language: 'el',
region: 'gr',
// Bias results toward Athens
bounds: `${ATHENS_BOUNDS.south},${ATHENS_BOUNDS.west}|${ATHENS_BOUNDS.north},${ATHENS_BOUNDS.east}`,
},
timeout: 10000,
},
);

if (response.data.status === 'ZERO_RESULTS') {
return null;
}

if (response.data.status !== 'OK') {
throw new Error(
`Google Geocoding API error: ${response.data.status} - ${response.data.error_message || 'Unknown error'}`,
);
}

const result = response.data.results[0];
if (!result?.geometry?.location) {
return null;
}

const { lat, lng } = result.geometry.location;

// Verify the result is within Athens bounds (with some margin)
const margin = 0.02; // ~2km margin
if (
lat < ATHENS_BOUNDS.south - margin ||
lat > ATHENS_BOUNDS.north + margin ||
lng < ATHENS_BOUNDS.west - margin ||
lng > ATHENS_BOUNDS.east + margin
) {
console.warn(
` ⚠ Result outside Athens bounds: ${result.formatted_address} (${lat}, ${lng})`,
);
return null;
}

return {
coordinates: [lng, lat], // GeoJSON uses [lng, lat]
formattedAddress: result.formatted_address,
};
}

function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

async function geocodeRegulation(
filePath: string,
options: { dryRun: boolean; force: boolean; delayMs: number },
): Promise<void> {
const absolutePath = path.resolve(filePath);
if (!fs.existsSync(absolutePath)) {
console.error(`Error: File not found at ${absolutePath}`);
process.exit(1);
}

const regulationData: RegulationData = JSON.parse(
fs.readFileSync(absolutePath, 'utf-8'),
);

// Collect all geometries that need geocoding
const toGeocode: Array<{
geometry: Geometry;
geosetName: string;
geosetId: string;
}> = [];

for (const item of regulationData.regulation) {
if (item.type !== 'geoset') continue;
const geoset = item as GeoSet;

for (const geometry of geoset.geometries) {
if (geometry.type !== 'point') continue;

// Skip if already has coordinates (unless --force)
if (geometry.geojson && !options.force) continue;

// Need either textualDefinition or name to geocode
const address = geometry.textualDefinition || geometry.name;
if (!address) continue;

toGeocode.push({
geometry,
geosetName: geoset.name,
geosetId: geoset.id,
});
}
}

console.log(`\nFound ${toGeocode.length} geometries to geocode.\n`);

if (toGeocode.length === 0) {
console.log('Nothing to geocode. All point geometries already have coordinates.');
return;
}

if (options.dryRun) {
console.log('DRY RUN - Would geocode these addresses:\n');
for (const { geometry, geosetName } of toGeocode) {
const address = geometry.textualDefinition || geometry.name;
console.log(` [${geosetName}] ${geometry.id}: ${address}`);
}
return;
}

// Geocode each address
const results: GeocodingResult[] = [];
let successCount = 0;
let failCount = 0;

for (let i = 0; i < toGeocode.length; i++) {
const { geometry, geosetName } = toGeocode[i];
const address = geometry.textualDefinition || geometry.name;

process.stdout.write(
`[${i + 1}/${toGeocode.length}] ${geosetName} > ${address}...`,
);

try {
const result = await geocodeAddress(address, geosetName);

if (result) {
geometry.geojson = {
type: 'Point',
coordinates: result.coordinates,
};
successCount++;
console.log(` ✓ ${result.formattedAddress}`);
results.push({
geometryId: geometry.id,
address,
status: 'success',
coordinates: result.coordinates,
formattedAddress: result.formattedAddress,
});
} else {
failCount++;
console.log(' ✗ ZERO_RESULTS');
results.push({
geometryId: geometry.id,
address,
status: 'zero_results',
});
}
} catch (error) {
failCount++;
const errorMsg =
error instanceof Error ? error.message : 'Unknown error';
console.log(` ✗ ERROR: ${errorMsg}`);
results.push({
geometryId: geometry.id,
address,
status: 'error',
error: errorMsg,
});
}

// Rate limiting
if (i < toGeocode.length - 1) {
await delay(options.delayMs);
}
}

// Save updated regulation JSON
fs.writeFileSync(absolutePath, JSON.stringify(regulationData, null, 2));

// Print summary
console.log('\n' + '='.repeat(60));
console.log('GEOCODING SUMMARY');
console.log('='.repeat(60));
console.log(`Total: ${toGeocode.length}`);
console.log(`Success: ${successCount}`);
console.log(`Failed: ${failCount}`);
console.log(`Updated file: ${absolutePath}`);

// List failures for manual review
const failures = results.filter((r) => r.status !== 'success');
if (failures.length > 0) {
console.log(`\nFailed addresses (${failures.length}) — use admin geo-editor to fix:`);
for (const f of failures) {
console.log(` - ${f.geometryId}: ${f.address} (${f.status}${f.error ? ': ' + f.error : ''})`);
}

// Save failures report
const reportPath = absolutePath.replace('.json', '-geocode-failures.json');
fs.writeFileSync(reportPath, JSON.stringify(failures, null, 2));
console.log(`\nFailure report saved to: ${reportPath}`);
}
}

// --- CLI ---
function main() {
const args = process.argv.slice(2);
const flags = args.filter((a) => a.startsWith('--'));
const positional = args.filter((a) => !a.startsWith('--'));

if (positional.length !== 1) {
console.log(
'Usage: npx tsx scripts/geocode-regulation-addresses.ts [options] <path_to_regulation.json>',
);
console.log('\nOptions:');
console.log(' --dry-run Preview what would be geocoded without making API calls');
console.log(' --force Re-geocode even if geojson already exists');
console.log(' --delay=N Delay between API calls in ms (default: 200)');
process.exit(1);
}

if (!GOOGLE_API_KEY) {
console.error('Error: GOOGLE_API_KEY environment variable is required');
console.error('Set it in your .env file or export it in your shell.');
process.exit(1);
}

const dryRun = flags.includes('--dry-run');
const force = flags.includes('--force');
const delayFlag = flags.find((f) => f.startsWith('--delay='));
const delayMs = delayFlag ? parseInt(delayFlag.split('=')[1], 10) : 200;

geocodeRegulation(positional[0], { dryRun, force, delayMs })
.then(() => {
console.log('\nDone.');
})
.catch((error) => {
console.error('\nFatal error:', error);
process.exit(1);
});
}

main();
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Metadata } from "next";
import { getCityCached } from "@/lib/cache";
import { getConsultationById, getConsultationComments } from "@/lib/db/consultations";
import { getConsultationById, getConsultationComments, fetchRegulationData } from "@/lib/db/consultations";
import { notFound } from "next/navigation";
import { RegulationData } from "@/components/consultations/types";
import { auth } from "@/auth";
Expand All @@ -13,22 +13,6 @@ interface PageProps {
params: { cityId: string; id: string };
}

async function fetchRegulationData(jsonUrl: string): Promise<RegulationData | null> {
try {
const response = await fetch(jsonUrl, { cache: 'no-store' });

if (!response.ok) {
console.error(`Failed to fetch regulation data: ${response.status}`);
return null;
}

return await response.json();
} catch (error) {
console.error('Error fetching regulation data:', error);
return null;
}
}

export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const [consultation, city] = await Promise.all([
getConsultationById(params.cityId, params.id),
Expand Down Expand Up @@ -274,7 +258,7 @@ export default async function CommentsPage({ params }: PageProps) {
}

// Check if consultations are enabled for this city
if (!(city as any).consultationsEnabled) {
if (!city.consultationsEnabled) {
notFound();
}

Expand Down
Loading