diff --git a/apps/api/src/modules/osm-sync/infrastructure/cron/osm-sync-cron.service.ts b/apps/api/src/modules/osm-sync/infrastructure/cron/osm-sync-cron.service.ts index e72aab3..8f3bfe9 100644 --- a/apps/api/src/modules/osm-sync/infrastructure/cron/osm-sync-cron.service.ts +++ b/apps/api/src/modules/osm-sync/infrastructure/cron/osm-sync-cron.service.ts @@ -77,4 +77,19 @@ export class OsmSyncCronService { await this.osmSync.run({ layer: 'industrial-parks', chunk, wait: true }); } } + + /** Refresh proximity / density mat views every Sunday after the weekly + * ward sync had time to settle. Always runs (cheap, no Overpass). */ + @Cron('0 4 * * 0', { timeZone: 'Asia/Ho_Chi_Minh' }) + async weeklyRefreshViews(): Promise { + try { + await this.osmSync.refreshMaterializedViews(); + } catch (err) { + this.logger.error( + `Refresh views failed: ${err instanceof Error ? err.message : err}`, + err instanceof Error ? err.stack : undefined, + 'OsmSyncCronService', + ); + } + } } diff --git a/apps/api/src/modules/osm-sync/infrastructure/osm-sync.service.ts b/apps/api/src/modules/osm-sync/infrastructure/osm-sync.service.ts index ab6f560..ab17e5e 100644 --- a/apps/api/src/modules/osm-sync/infrastructure/osm-sync.service.ts +++ b/apps/api/src/modules/osm-sync/infrastructure/osm-sync.service.ts @@ -78,6 +78,22 @@ export class OsmSyncService { private readonly logger: LoggerService, ) {} + /** + * Refresh the proximity / density materialized views. Called by the + * cron and from the admin "Refresh views" button. Runs concurrently + * (`CONCURRENTLY`) so reads aren't blocked. + */ + async refreshMaterializedViews(): Promise { + this.logger.log('Refreshing materialized views', 'OsmSyncService'); + await this.prisma.$executeRawUnsafe( + `REFRESH MATERIALIZED VIEW CONCURRENTLY "mv_park_nearest_poi"`, + ); + await this.prisma.$executeRawUnsafe( + `REFRESH MATERIALIZED VIEW "mv_poi_density_by_province"`, + ); + this.logger.log('Materialized views refreshed', 'OsmSyncService'); + } + /** Look up a sync layer by its (layer, category) tuple. */ findLayer(layer: string, category?: string | null): OsmSyncLayerDef | undefined { return SYNC_LAYERS.find( diff --git a/apps/api/src/modules/osm-sync/presentation/controllers/osm-sync.controller.ts b/apps/api/src/modules/osm-sync/presentation/controllers/osm-sync.controller.ts index ed5ad03..a6d55b8 100644 --- a/apps/api/src/modules/osm-sync/presentation/controllers/osm-sync.controller.ts +++ b/apps/api/src/modules/osm-sync/presentation/controllers/osm-sync.controller.ts @@ -61,4 +61,14 @@ export class OsmSyncController { new TriggerOsmSyncCommand(dto.layer, dto.category, dto.chunk), ); } + + @ApiOperation({ + summary: 'Refresh proximity materialized views', + description: 'Recomputes mv_park_nearest_poi + mv_poi_density_by_province.', + }) + @Post('refresh-views') + async refreshViews(): Promise<{ ok: true }> { + await this.osmSync.refreshMaterializedViews(); + return { ok: true }; + } } diff --git a/prisma/migrations/20260501030000_add_poi_proximity_views/migration.sql b/prisma/migrations/20260501030000_add_poi_proximity_views/migration.sql new file mode 100644 index 0000000..2ade4e1 --- /dev/null +++ b/prisma/migrations/20260501030000_add_poi_proximity_views/migration.sql @@ -0,0 +1,39 @@ +-- Phase 6: cached proximity scores for fast filter / sort. +-- Refreshed by `OsmRefreshViewsCron` (Sun 03:00 ICT) and on admin-trigger. + +-- For each industrial park, the 3 nearest POI of each priority category. +CREATE MATERIALIZED VIEW IF NOT EXISTS "mv_park_nearest_poi" AS +SELECT + p.id AS park_id, + poi.category::text AS category, + poi.id AS poi_id, + poi.name AS poi_name, + ROUND(ST_Distance(p.location::geography, poi.location::geography)::numeric, 0)::int AS distance_m, + ROW_NUMBER() OVER ( + PARTITION BY p.id, poi.category + ORDER BY ST_Distance(p.location::geography, poi.location::geography) + ) AS rank +FROM "IndustrialPark" p +CROSS JOIN LATERAL ( + SELECT id, name, category, location + FROM "Poi" + WHERE "isPublic" = true + AND category::text IN ('HOSPITAL','BANK','GAS_STATION','BUS_STATION','METRO_STATION','POLICE') + AND ST_DWithin(location::geography, p.location::geography, 5000) +) poi; + +CREATE INDEX IF NOT EXISTS "mv_park_nearest_poi_park_cat" ON "mv_park_nearest_poi"(park_id, category, rank); +CREATE UNIQUE INDEX IF NOT EXISTS "mv_park_nearest_poi_uq" ON "mv_park_nearest_poi"(park_id, category, poi_id); + +-- Per-province POI density (count by category) for analytics heatmaps. +CREATE MATERIALIZED VIEW IF NOT EXISTS "mv_poi_density_by_province" AS +SELECT + "provinceCode", + category::text AS category, + COUNT(*)::int AS poi_count +FROM "Poi" +WHERE "isPublic" = true + AND "provinceCode" IS NOT NULL +GROUP BY "provinceCode", category; + +CREATE INDEX IF NOT EXISTS "mv_poi_density_by_province_idx" ON "mv_poi_density_by_province"("provinceCode", category);