-
Notifications
You must be signed in to change notification settings - Fork 17
Add Consultation #195
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
kouloumos
wants to merge
20
commits into
schemalabz:main
Choose a base branch
from
kouloumos:consultation-bins
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Add Consultation #195
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 3ac54b4
feat: add geocoding script for regulation addresses
kouloumos 0098253
feat: add cooking oil regulation generation script
kouloumos c4bf0c9
feat: add admin page for managing consultations
kouloumos a126471
fix: resolve relative URLs when fetching regulation JSON server-side
kouloumos b89bd7f
docs: add scripts and tooling section to consultations guide
kouloumos dc407b2
fix: scroll to top when navigating to map view via references
kouloumos 1b936ff
feat: add "apply searched location" button to geo-editor
kouloumos 734d088
feat: zoom to geometry on click and hash navigation in map view
kouloumos f9ba9dd
feat: add S3 upload and inline URL editing to admin consultations
kouloumos ade8f84
feat: add community picker with address search and welcome dialog to …
kouloumos b3a142d
fix: use verified domain for consultation emails and show recipients …
kouloumos 3cee77e
fix: make .next/server/app writable in preview deployments for ISR
kouloumos 752d0b2
feat: render consultation panels as bottom sheet drawers on mobile
kouloumos 6c967b5
refactor: extract getSafeHtmlContent to shared utility
kouloumos 8ab5427
refactor: extract createCircleBuffer to shared geo utility
kouloumos de2c24f
refactor: deduplicate CommentsOverviewSheet Drawer/Sheet branches
kouloumos 2740688
refactor: move admin consultation queries to db layer and use server …
kouloumos 87e26f2
refactor: consolidate geo utilities into src/lib/geo.ts
kouloumos 5722304
test: add unit tests for geo utilities
kouloumos File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.