From ecf2cb1836d84a0e82ede3607482ccf26275132b Mon Sep 17 00:00:00 2001 From: Babayaga Date: Wed, 1 Apr 2026 17:24:08 +0200 Subject: [PATCH] map fixes --- packages/ui/docs/acl.md | 662 ++++++++++++++++++ packages/ui/docs/locations/algo.md | 50 -- packages/ui/docs/locations/enricher.md | 114 --- packages/ui/docs/locations/gadm.md | 82 --- packages/ui/docs/locations/grid-search-ex.md | 268 ------- packages/ui/docs/locations/gridsearch.md | 163 ----- packages/ui/docs/places/gadm.md | 54 -- packages/ui/docs/products-crud.md | 52 ++ packages/ui/docs/products.md | 69 ++ packages/ui/shared/src/competitors/schemas.ts | 34 +- .../widgets/CompetitorsMapWidget.tsx | 151 ++-- .../widgets/location-picker/SearchesTab.tsx | 6 +- .../widgets/useCompetitorsMapData.ts | 210 ++++++ packages/ui/src/lib/registerWidgets.ts | 13 +- .../ui/src/modules/places/LocationDetail.tsx | 8 +- ...etitorsGridView.tsx => PlacesGridView.tsx} | 57 +- ...mpetitorsMapView.tsx => PlacesMapView.tsx} | 176 ++--- ...etitorsMetaView.tsx => PlacesMetaView.tsx} | 14 +- ...ngsDialog.tsx => PlacesSettingsDialog.tsx} | 8 +- ...itorsThumbView.tsx => PlacesThumbView.tsx} | 44 +- .../places/components/MapPosterOverlay.tsx | 16 +- .../modules/places/gadm-picker/GadmPicker.tsx | 81 ++- .../places/gridsearch/GridSearchResults.tsx | 40 +- .../places/gridsearch/GridSearchWizard.tsx | 11 +- ...orsReportView.tsx => PlacesReportView.tsx} | 8 +- .../ui/src/modules/places/useGridColumns.tsx | 12 +- ...etitorSettings.ts => usePlacesSettings.ts} | 11 +- 27 files changed, 1327 insertions(+), 1087 deletions(-) create mode 100644 packages/ui/docs/acl.md delete mode 100644 packages/ui/docs/locations/algo.md delete mode 100644 packages/ui/docs/locations/enricher.md delete mode 100644 packages/ui/docs/locations/gadm.md delete mode 100644 packages/ui/docs/locations/grid-search-ex.md delete mode 100644 packages/ui/docs/locations/gridsearch.md delete mode 100644 packages/ui/docs/places/gadm.md create mode 100644 packages/ui/docs/products-crud.md create mode 100644 packages/ui/docs/products.md create mode 100644 packages/ui/src/components/widgets/useCompetitorsMapData.ts rename packages/ui/src/modules/places/{CompetitorsGridView.tsx => PlacesGridView.tsx} (94%) rename packages/ui/src/modules/places/{CompetitorsMapView.tsx => PlacesMapView.tsx} (83%) rename packages/ui/src/modules/places/{CompetitorsMetaView.tsx => PlacesMetaView.tsx} (92%) rename packages/ui/src/modules/places/{CompetitorSettingsDialog.tsx => PlacesSettingsDialog.tsx} (93%) rename packages/ui/src/modules/places/{CompetitorsThumbView.tsx => PlacesThumbView.tsx} (82%) rename packages/ui/src/modules/places/gridsearch/{CompetitorsReportView.tsx => PlacesReportView.tsx} (94%) rename packages/ui/src/modules/places/{useCompetitorSettings.ts => usePlacesSettings.ts} (89%) diff --git a/packages/ui/docs/acl.md b/packages/ui/docs/acl.md new file mode 100644 index 00000000..54ca2d09 --- /dev/null +++ b/packages/ui/docs/acl.md @@ -0,0 +1,662 @@ +# ACL Management — Endpoint-Level, Database-Driven + +> **Status**: Proposal +> **Scope**: Server-side ACL enforcement, admin CRUD, OpenAPI-first endpoint registry, product & cost metadata +> **Out of scope (for now)**: Billing/invoicing, UI + +--- + +## 1. Problem Statement + +The platform has **two disconnected permission layers** and a hardcoded product config that don't compose: + +| Layer | Mechanism | Granularity | +|-------|-----------|-------------| +| `auth.ts` middleware | `PublicEndpointRegistry`, `AdminEndpointRegistry` | Binary: public vs auth vs admin | +| `db-acl.ts` orchestrator | Per-resource backends (VFS file-based, category DB-backed) | Resource-level ACL entries | + +Additionally, `products.ts` hardcodes `PRODUCT_ACTIONS` with per-endpoint `costUnits` — this should move to the database so products and their endpoints are fully manageable via admin CRUD. + +**What's missing:** + +- No way for admins to **manage endpoint access** from a database — everything is code-deployed +- No **group/tier** concept for API endpoints (e.g. "free users get 10 searches/day, pro users get 1000") +- No standard way to express "user X can create pages but not posts" or "group Y can use the AI image generator" +- The `resource_acl` table handles per-object ACL (who can view this category) but not per-endpoint ACL (who can call POST /api/pages) +- No mechanism to feed fine-grained permissions to the frontend (e.g. "hide the Create Page button for this user") + +--- + +## 2. Design Goals + +1. **Zero new tables** — Everything fits in the existing `resource_acl` table using new `resource_type` conventions. +2. **OpenAPI-first** — The canonical list of "things you can control" is the OpenAPI spec (`/doc`). Every registered route has a `path`, `method`, and `tags[]`. ACL rules reference these directly. +3. **Database-driven** — All ACL configuration lives in Postgres, manageable via admin API. No more code deploys to change who can do what. +4. **Groups with inheritance** — ACL groups (tiers) can inherit from parent groups. `pro` inherits everything from `free`, then adds more. +5. **Rate-limit tiers** — Each group can have per-endpoint rate limits. The rate limiter middleware reads these from the ACL tables. +6. **Capability surfacing** — A single API call returns the authenticated user's effective permissions, consumable by the frontend to show/hide UI elements. +7. **Backward compatible** — Existing `PublicEndpointRegistry` / `AdminEndpointRegistry` continue to work. The new system layers on top. + +--- + +## 3. Data Model — `resource_acl` Convention Map + +### 3.0 Existing table (unchanged) + +```sql +create table public.resource_acl ( + id uuid primary key default gen_random_uuid(), + resource_type text not null, + resource_id text not null, + resource_owner_id uuid references auth.users(id), + user_id uuid references auth.users(id) on delete cascade, + group_name text, + permissions text[] not null default '{}', + path text default '/', + meta jsonb default '{}', + log jsonb default '{}', + created_at timestamptz default now(), + updated_at timestamptz default now(), + constraint check_grantee check ( + (user_id is not null and group_name is null) or + (user_id is null and group_name is not null) or + (user_id is null and group_name is null) -- definition-only rows + ) +); +``` + +The `check_grantee` constraint already allows rows where **both** `user_id` and `group_name` are `NULL` — these are perfect for definition/config rows (products, endpoints, groups). + +### 3.1 New `resource_type` conventions + +All new concepts map to `resource_type` discriminators on the **same table**: + +| Concept | `resource_type` | `resource_id` | `user_id` | `group_name` | `permissions` | `path` | `meta` | +|---------|----------------|---------------|-----------|-------------|---------------|--------|--------| +| **Product → Group rule** | `product-acl` | product slug: `places` | — | `pro` | `[]` | `/` | `{effect, rate_limit, rate_window}` — applies to ALL endpoints in product | +| **Product → User override** | `product-acl` | product slug: `places` | `uuid` | — | `[]` | `/` | `{effect, rate_limit, rate_window, reason}` | +| **Endpoint def** | `endpoint` | `GET:/api/pages` | — | — | `[]` | `/api/pages` | `{tag, summary, product, cost_units, cancellable, is_public, is_admin}` | +| **Endpoint → Group rule** | `endpoint-acl` | `GET:/api/pages` | — | `editor` | `['create','update']` | `/` | `{effect, rate_limit, rate_window}` | +| **Endpoint → User override** | `endpoint-acl` | `GET:/api/pages` | `uuid` | — | `['create']` | `/` | `{effect, rate_limit, rate_window, reason, granted_by, expires_at}` | +| **Group def** | `acl-group` | slug: `editor` | — | — | `[]` | `/` | `{name, description, parent, priority, is_default}` | +| **Group member** | `acl-group-member` | group slug: `editor` | `uuid` | — | `[]` | `/` | `{granted_by, expires_at}` | +| VFS ACL (existing) | `vfs` | folder id | `uuid` or — | group or — | perms | scoped path | — | +| Category ACL (existing) | `category` | category id | `uuid` or — | group or — | perms | `/` | — | + +> **Product-level vs endpoint-level rules:** A `product-acl` rule is a shorthand for "all endpoints in this product". When both exist, endpoint-level rules (`endpoint-acl`) override product-level rules (`product-acl`). This means admins can set a blanket rule for a product and then override specific endpoints. + +### 3.2 Column reuse rationale + +| Column | Original purpose | Reused for | +|--------|-----------------|------------| +| `resource_type` | Discriminator (`vfs`, `category`) | New types: `product-acl`, `endpoint`, `endpoint-acl`, `acl-group`, `acl-group-member` | +| `resource_id` | Resource identifier | Product slug, endpoint key (`METHOD:path`), group slug | +| `path` | VFS scoped path | Product prefix (`/api/places`), endpoint URL path | +| `permissions` | VFS/category perms | Endpoint fine-grained perms (`create`, `update`, `delete`) | +| `group_name` | VFS group grant | ACL group slug for endpoint rules | +| `user_id` | VFS user grant | Group membership, user overrides | +| `meta` | Extensible metadata | All config: costs, rate limits, tags, descriptions | +| `resource_owner_id` | VFS folder owner | Not used for new types (NULL) | +| `log` | Audit trail | Audit trail for rule changes | + +### 3.3 Required schema changes + +Only **one new index** needed — the existing indexes already cover the primary lookups: + +```sql +-- New: fast group membership lookups (user → which groups?) +create index if not exists idx_resource_acl_group_name + on resource_acl(group_name) + where group_name is not null; + +-- New: fast resource_type filtering for the new conventions +create index if not exists idx_resource_acl_type_id + on resource_acl(resource_type, resource_id) + where resource_type in ('product-acl', 'endpoint', 'endpoint-acl', 'acl-group', 'acl-group-member'); +``` + +### 3.4 Seeding + +```sql +-- Default ACL groups +insert into resource_acl (resource_type, resource_id, meta) values + ('acl-group', 'anonymous', '{"name":"Anonymous","priority":0,"is_default":false}'), + ('acl-group', 'authenticated', '{"name":"Authenticated","priority":10,"is_default":true}'), + ('acl-group', 'editor', '{"name":"Editor","priority":20,"is_default":false,"parent":"authenticated"}'), + ('acl-group', 'admin', '{"name":"Admin","priority":100,"is_default":false,"parent":"editor"}'); +``` + +### 3.5 Example rows + +``` +# ── Note: Products themselves are defined in a separate `products` table ── +# e.g., A row in the 'products' table where slug='places' and settings='{"default_cost_units": 1.0}' + +# ── Product-level ACL ─────────────────────────────────────── +# "free" group gets access to places product, 10 calls/day across ALL its endpoints +resource_type=product-acl resource_id=places group_name=free + meta={effect:"allow", rate_limit:10, rate_window:86400} + +# "pro" group gets 1000 calls/day across the whole product +resource_type=product-acl resource_id=places group_name=pro + meta={effect:"allow", rate_limit:1000, rate_window:86400} + +# ── Endpoint definition (auto-synced from OpenAPI) ────────── +resource_type=endpoint resource_id=GET:/api/places/search path=/api/places/search + meta={tag:"Places", summary:"Search places", product:"places", cost_units:1.0, cancellable:true} + +# ── Endpoint-level ACL override ───────────────────────────── +# Override: the find-email endpoint is more expensive, tighter limit for free +resource_type=endpoint-acl resource_id=GET:/api/places/email/:id group_name=free + meta={effect:"allow", rate_limit:3, rate_window:86400} + +# ── User override ─────────────────────────────────────────── +# VIP user gets 5000/day on the full product +resource_type=product-acl resource_id=places user_id= + meta={effect:"allow", rate_limit:5000, rate_window:86400, reason:"VIP customer"} + +# ── Content creation example ──────────────────────────────── +resource_type=endpoint-acl resource_id=POST:/api/pages group_name=editor + permissions=[create] meta={effect:"allow"} + +resource_type=endpoint-acl resource_id=PUT:/api/pages/:id group_name=editor + permissions=[update] meta={effect:"allow"} + +resource_type=endpoint-acl resource_id=DELETE:/api/pages/:id group_name=editor + permissions=[] meta={effect:"deny"} +``` + +### ER Diagram (logical, single table) + +```mermaid +erDiagram + resource_acl { + uuid id PK + text resource_type "product-acl | endpoint | endpoint-acl | acl-group | acl-group-member | vfs | category" + text resource_id "slug or METHOD:path" + uuid resource_owner_id FK "nullable" + uuid user_id FK "nullable" + text group_name "nullable" + text[] permissions + text path "URL prefix or scoped path" + jsonb meta "all config & metadata" + jsonb log "audit trail" + } + + resource_acl ||--o{ resource_acl : "group → members (via resource_id match)" + resource_acl ||--o{ resource_acl : "group → rules (via group_name match)" + resource_acl ||--o{ resource_acl : "endpoint → rules (via resource_id match)" +``` + +--- + +## 4. OpenAPI Endpoint Sync + +On server boot, the system auto-syncs the OpenAPI spec into `resource_acl`: + +``` +Boot flow: + 1. app.doc31('/doc', ...) generates the OpenAPI spec + 2. syncEndpointsFromOpenAPI() reads the spec + 3. For each operation: + UPSERT into resource_acl where resource_type='endpoint' and resource_id='METHOD:/path' + Sets meta: {tag, summary, is_public, is_admin, product (by longest prefix match)} + 4. Mark stale endpoints (in DB but not in spec) via meta.deprecated = true +``` + +### Product assignment logic + +During sync, each endpoint's `path` is matched against product rows in the *separate `products` table* (via prefix matching). Longest prefix wins: + +``` +/api/places/search → matches product 'places' (by settings prefix or path convention) +/api/images/upload → matches product 'images' +``` + +### Source: `PublicEndpointRegistry` + `AdminEndpointRegistry` + +The existing decorators (`Public()`, `Admin()`) already populate these registries. The sync step reads both to set `meta.is_public` and `meta.is_admin` flags. + +--- + +## 5. Permission Resolution Algorithm + +When a request hits the middleware: + +``` +1. Extract user from JWT (or 'anonymous' sentinel) + +2. Load user's groups: + SELECT resource_id FROM resource_acl + WHERE resource_type = 'acl-group-member' AND user_id = $1 + UNION + SELECT resource_id FROM resource_acl + WHERE resource_type = 'acl-group' AND (meta->>'is_default')::boolean = true + → then walk parent chain via meta.parent + +3. Match endpoint → product: + a. Find resource_type='endpoint' row matching the request path+method + b. Read meta.product to get the product slug + +4. Load effective rules (two tiers): + a. Endpoint-level rules: + SELECT * FROM resource_acl + WHERE resource_type = 'endpoint-acl' + AND resource_id = $endpoint_key + AND (group_name = ANY($user_groups) OR user_id = $user_id) + b. Product-level rules (fallback if no endpoint-level match): + SELECT * FROM resource_acl + WHERE resource_type = 'product-acl' + AND resource_id = $product_slug + AND (group_name = ANY($user_groups) OR user_id = $user_id) + +5. Apply priority ordering: + - User overrides (user_id set) beat group rules + - Endpoint-level rules beat product-level rules + - Higher-priority groups override lower-priority groups + - Explicit 'deny' overrides 'allow' at same priority + +6. Resolve cost: + endpoint.meta.cost_units ?? settings.default_cost_units (from separate products table) ?? 0 + +7. Resolve rate limit: + endpoint-acl rule > product-acl rule > settings.default_rate_limit (from products table) + (user override always wins over group rule at any level) + +8. Return allow/deny + effective rate limit + cost_units + permissions +``` + +### Caching Strategy + +``` +Cache key: `endpoint-acl:${userId}` +TTL: 5 minutes (same as existing ACL_CACHE_TTL) +Invalidation: On membership change, rule change, or override change +``` + +### Query examples + +```sql +-- All groups for a user (including defaults and parents) +WITH RECURSIVE user_groups AS ( + -- Direct memberships + SELECT r.resource_id AS group_slug + FROM resource_acl r + WHERE r.resource_type = 'acl-group-member' + AND r.user_id = $1 + AND (r.meta->>'expires_at' IS NULL OR (r.meta->>'expires_at')::timestamptz > now()) + UNION + -- Default groups + SELECT r.resource_id + FROM resource_acl r + WHERE r.resource_type = 'acl-group' + AND (r.meta->>'is_default')::boolean = true +), +-- Walk parent chain +group_tree AS ( + SELECT g.group_slug, g2.meta->>'parent' AS parent_slug, + (g2.meta->>'priority')::int AS priority + FROM user_groups g + JOIN resource_acl g2 ON g2.resource_type = 'acl-group' + AND g2.resource_id = g.group_slug + UNION + SELECT gt.parent_slug, g2.meta->>'parent', + (g2.meta->>'priority')::int + FROM group_tree gt + JOIN resource_acl g2 ON g2.resource_type = 'acl-group' + AND g2.resource_id = gt.parent_slug + WHERE gt.parent_slug IS NOT NULL +) +SELECT DISTINCT group_slug, priority FROM group_tree ORDER BY priority DESC; + +-- Effective rules for an endpoint + user's groups +SELECT r.*, g.meta->>'priority' AS group_priority +FROM resource_acl r +LEFT JOIN resource_acl g ON g.resource_type = 'acl-group' + AND g.resource_id = r.group_name +WHERE r.resource_type = 'endpoint-acl' + AND r.resource_id = $endpoint_key + AND (r.group_name = ANY($user_group_slugs) OR r.user_id = $user_id) +ORDER BY + CASE WHEN r.user_id IS NOT NULL THEN 1000 ELSE 0 END + + COALESCE((g.meta->>'priority')::int, 0) DESC; +``` + +--- + +## 6. API Endpoints (Admin) + +All under `/api/admin/acl/` — admin-only. + +### 6.1 Products + +Products are defined in their own `products` table. They serve as the **grouping unit** for endpoints and define: +- **defaults**: `default_cost_units`, `default_rate_limit`, `default_rate_window` (stored in the JSONB settings column, inherited by all child endpoints) +- **enabled/disabled**: disabling a product sets `settings.enabled=false` denying all its endpoints + +Product-level ACL rules (`product-acl` in `resource_acl`) let admins set blanket access per group without creating N rules per endpoint. + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/admin/products` | List all products from `products` table | +| `POST` | `/api/admin/products` | Create product in `products` table | +| `PUT` | `/api/admin/products/:slug` | Update product in `products` table | +| `DELETE` | `/api/admin/products/:slug` | Delete product from `products` table | +| `GET` | `/api/admin/acl/products/:slug/rules` | List product-level ACL rules (`product-acl` in `resource_acl`) | +| `POST` | `/api/admin/acl/products/:slug/rules` | Create product-level ACL rule (group or user) | +| `DELETE` | `/api/admin/acl/products/:slug/rules/:id` | Delete product-level rule | + +### 6.2 Endpoints Registry + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/admin/acl/endpoints` | List all endpoints (filters: tag, product, is_public, is_admin) | +| `PUT` | `/api/admin/acl/endpoints/:id` | Update endpoint (cost_units, product, cancellable) | +| `POST` | `/api/admin/acl/endpoints/sync` | Force re-sync from OpenAPI spec | + +### 6.3 Groups CRUD + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/admin/acl/groups` | List all groups (with membership counts) | +| `POST` | `/api/admin/acl/groups` | Create group | +| `PUT` | `/api/admin/acl/groups/:slug` | Update group | +| `DELETE` | `/api/admin/acl/groups/:slug` | Delete group (cascade rules) | + +### 6.4 Group Members + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/admin/acl/groups/:slug/members` | List members | +| `POST` | `/api/admin/acl/groups/:slug/members` | Add member(s) | +| `DELETE` | `/api/admin/acl/groups/:slug/members/:userId` | Remove member | + +### 6.5 Rules + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/admin/acl/rules` | List all rules (filter by group, endpoint, product, tag) | +| `POST` | `/api/admin/acl/rules` | Create/update rule | +| `DELETE` | `/api/admin/acl/rules/:id` | Delete rule | +| `POST` | `/api/admin/acl/rules/batch` | Bulk create/update rules | + +### 6.6 User Overrides + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/admin/acl/overrides/:userId` | Get overrides for user | +| `POST` | `/api/admin/acl/overrides` | Create override | +| `DELETE` | `/api/admin/acl/overrides/:id` | Delete override | + +### 6.7 Capability Query (Non-Admin) + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/acl/capabilities` | Returns the calling user's effective permissions across all endpoints | + +Response shape: + +```json +{ + "groups": ["authenticated", "editor"], + "capabilities": { + "POST /api/pages": { + "allowed": true, + "permissions": ["create"], + "rateLimit": { "max": 100, "windowSec": 3600 } + }, + "POST /api/posts": { + "allowed": true, + "permissions": ["create", "update"], + "rateLimit": null + }, + "GET /api/competitors": { + "allowed": false, + "reason": "upgrade_required" + } + }, + "tags": { + "Pages": { "create": true, "update": true, "delete": false }, + "Posts": { "create": true, "update": true, "delete": true }, + "AI": { "generate_image": false } + } +} +``` + +The `tags` summary is the **key UI bridge** — frontend can check `capabilities.tags.Pages.create` to decide whether to show the "New Page" button. + +--- + +## 7. Middleware Integration + +### New middleware: `aclMiddleware` + +Inserted **after** `optionalAuthMiddleware` and **before** route handlers: + +``` +app.use('/api/*', optionalAuthMiddleware) +app.use('/api/*', adminMiddleware) +app.use('/api/*', aclMiddleware) // ← NEW +``` + +```typescript +// Pseudocode for aclMiddleware +export async function aclMiddleware(c: Context, next: Next) { + const path = c.req.path; + const method = c.req.method; + + // 1. Skip for public endpoints (already handled) + if (PublicEndpointRegistry.isPublic(path, method)) { + return await next(); + } + + // 2. Admin bypass + if (c.get('isAdmin')) { + return await next(); + } + + // 3. Resolve user + const userId = c.get('userId') || 'anonymous'; + + // 4. Check endpoint ACL + const result = await resolveEndpointAcl(userId, path, method); + + if (result.denied) { + return c.json({ + error: 'Forbidden', + reason: result.reason, // 'no_permission' | 'rate_limited' | 'upgrade_required' + upgrade: result.upgradeHint // optional: which group would unlock this + }, 403); + } + + // 5. Apply dynamic rate limit + if (result.rateLimit) { + const limited = await checkDynamicRateLimit(userId, path, method, result.rateLimit); + if (limited) { + return c.json({ + error: 'Rate limit exceeded', + limit: result.rateLimit.max, + windowSec: result.rateLimit.windowSec, + retryAfter: limited.retryAfterSec + }, 429); + } + } + + // 6. Attach capabilities to context (for downstream handlers) + c.set('aclPermissions', result.permissions); + c.set('aclGroups', result.groups); + + await next(); +} +``` + +--- + +## 8. Relationship to Existing Systems + +### Coexistence with existing `resource_acl` usage + +| `resource_type` | Controls | Example | +|-----------------|----------|---------| +| `vfs` | VFS folder access per user/group | Team can read `/shared` | +| `category` | Category visibility | Anonymous can view category X | +| `product` | **NEW:** Product definitions | Product `places` with prefix `/api/places` | +| `endpoint` | **NEW:** Endpoint registry | `GET:/api/competitors` with cost_units=1.0 | +| `acl-group` | **NEW:** Permission group defs | `editor` group with priority 20 | +| `acl-group-member` | **NEW:** User ↔ group mapping | Alice is in group `pro` | +| `endpoint-acl` | **NEW:** Endpoint permissions | Group `free` gets 10 searches/day | + +All share the same table, same indexes, same `IAclBackend` pattern. The `resource_type` discriminator keeps them cleanly separated. + +### Subsumes `PRODUCT_ACTIONS` (config/products.ts) + +The hardcoded `PRODUCT_ACTIONS` map is **replaced** by `resource_type='product'` + `resource_type='endpoint'` rows: + +| Old (`products.ts`) | New (`resource_acl`) | +|---------------------|----------------------| +| `endpoint: '/api/competitors'` | `resource_id = 'GET:/api/competitors'`, `path = '/api/competitors'` | +| `method: 'GET'` | Encoded in `resource_id` prefix | +| `costUnits: 1.0` | `meta.cost_units = 1.0` | +| `cancellable: true` | `meta.cancellable = true` | +| product key `'competitors'` | `meta.product = 'places'` → links to product row | + +**Migration path:** +1. Add 2 new indexes to `resource_acl` +2. Seed `resource_type='product'` rows from `products.json` topology +3. First boot sync populates `resource_type='endpoint'` rows and auto-assigns products +4. Admin reviews and sets `meta.cost_units` for metered endpoints +5. `FunctionRegistry.findByRoute()` switches from reading `PRODUCT_ACTIONS` to querying `resource_acl` (cached) +6. `products.ts` config becomes dead code and is removed + +### Migration of `PublicEndpointRegistry` / `AdminEndpointRegistry` + +These become **seed data** for `resource_type='endpoint'` rows. The registries continue to function at the code level (for boot-time routing), and `syncEndpointsFromOpenAPI()` mirrors them into the database. + +--- + +## 9. Implementation Phases + +### Phase 1: Schema + Sync (Foundation) + +- [ ] Add 2 new indexes to `resource_acl` +- [ ] Seed `resource_type='product'` rows from `products.json` topology +- [ ] Seed default `resource_type='acl-group'` rows (anonymous, authenticated, editor, admin) +- [ ] Write `syncEndpointsFromOpenAPI()` function (auto-assigns products by prefix) +- [ ] Run on boot after `registerProductRoutes()` +- [ ] Register new `IAclBackend` implementations: `ProductBackend`, `EndpointBackend`, `GroupBackend` +- [ ] Admin API: `GET /api/admin/acl/endpoints`, `GET /api/admin/acl/products` +- [ ] Admin API: `PUT /api/admin/acl/endpoints/:id` (set cost_units, product, cancellable) +- [ ] Migrate `FunctionRegistry.findByRoute()` to read from `resource_acl` cache + +### Phase 2: Groups + Rules CRUD + +- [ ] Admin API for groups, members, rules, overrides +- [ ] Zod schemas for all admin endpoints +- [ ] Group membership with expiry support + +### Phase 3: Middleware Enforcement + +- [ ] `aclMiddleware` with permission resolution +- [ ] Dynamic rate limiting integration +- [ ] Cache layer with invalidation hooks + +### Phase 4: Capabilities API + +- [ ] `GET /api/acl/capabilities` for frontend consumption +- [ ] Tag-level permission summaries +- [ ] Frontend integration (capability checks for UI gating) + +--- + +## 10. Example Scenarios + +### Scenario A: Product-level rate limiting (the common case) + +Admin sets up the `places` product with tiered access — one `product-acl` rule per group covers ALL endpoints under `/api/places/*`: + +``` +# Groups +resource_type=acl-group resource_id=free meta={priority:10, is_default:true} +resource_type=acl-group resource_id=pro meta={priority:20, parent:"free"} + +# Product definition in 'products' table via SQL or admin UI: +# INSERT INTO products (name, slug, settings) VALUES ('places', 'places', '{"enabled":true, "default_cost_units": 1.0}') + +# Product-level rules (blanket for the whole product) +resource_type=product-acl resource_id=places group_name=free + meta={effect:"allow", rate_limit:10, rate_window:86400} ← 10/day + +resource_type=product-acl resource_id=places group_name=pro + meta={effect:"allow", rate_limit:1000, rate_window:86400} ← 1000/day +``` + +Free user hits `GET /api/places/search` or `GET /api/places/details/:id` → both rate limited at 10/day total. +Pro user → 1000/day total across all places endpoints. + +### Scenario B: Endpoint-level override within a product + +The `find-email` endpoint under `places` is expensive. Admin adds an endpoint-level rule that overrides the product blanket: + +``` +# This overrides the product-acl for free users, specifically for find-email +resource_type=endpoint-acl resource_id=GET:/api/places/email/:id group_name=free + meta={effect:"allow", rate_limit:3, rate_window:86400} ← only 3/day for free + +# Pro users: no endpoint override → inherits product-acl (1000/day) +``` + +Free user: 10/day for all places endpoints EXCEPT find-email which is 3/day. +Pro user: 1000/day for everything including find-email. + +### Scenario C: Content creation permissions + +``` +resource_type=endpoint-acl resource_id=POST:/api/pages group_name=editor + permissions=[create] meta={effect:"allow"} + +resource_type=endpoint-acl resource_id=PUT:/api/pages/:id group_name=editor + permissions=[update] meta={effect:"allow"} + +resource_type=endpoint-acl resource_id=DELETE:/api/pages/:id group_name=editor + permissions=[] meta={effect:"deny"} +``` + +Frontend calls `GET /api/acl/capabilities`: +```json +{ + "tags": { + "Pages": { "create": true, "update": true, "delete": false }, + "Posts": { "create": true, "update": true, "delete": false } + } +} +``` +→ UI hides delete buttons, shows create/edit buttons. + +### Scenario D: User-specific override at product level + +``` +User 'alice' is in group 'free' (10 places calls/day via product-acl) + +resource_type=product-acl resource_id=places user_id= + meta={effect:"allow", rate_limit:500, rate_window:86400, reason:"VIP customer"} +``` +→ Alice gets 500/day on ALL places endpoints despite being in the free group. +(User overrides always win over group rules.) + +--- + +## 11. File Structure + +``` +server/src/ +├── middleware/ +│ └── acl.ts # aclMiddleware (Phase 3) +├── products/serving/db/ +│ ├── db-acl.ts # existing resource ACL orchestrator (extended with new backends) +│ ├── db-acl-db.ts # existing DB backend (unchanged) +│ ├── db-acl-vfs.ts # existing VFS backend (unchanged) +│ ├── db-endpoint-acl.ts # NEW: endpoint ACL resolution engine +│ ├── db-endpoint-acl-sync.ts # NEW: OpenAPI → resource_acl sync +│ ├── db-endpoint-acl-admin.ts # NEW: admin CRUD handlers +│ └── db-endpoint-acl-routes.ts # NEW: route definitions +``` diff --git a/packages/ui/docs/locations/algo.md b/packages/ui/docs/locations/algo.md deleted file mode 100644 index 51ae1d4d..00000000 --- a/packages/ui/docs/locations/algo.md +++ /dev/null @@ -1,50 +0,0 @@ -# Search Algorithm Strategies - -## 1. Zoom Level Calibration -**Objective**: Determine the optimal zoom level to maximize the number of unique locations found for a given keyword and location. - -### Strategy -1. **Input**: - - Keyword (e.g., "carpenters") - - Location (e.g., "Barcelona, Spain") - - Zoom Range (e.g., 12-18) - -2. **Process**: - - Iterate through the defined range of zoom levels. - - For each zoom level: - - Perform a Google Maps search using the `googleMaps` function. - - Store the results in a JSON file using the `--dst` option (e.g., `./tmp/search/test-zoomlevel-.json`). - - Count the total number of valid results returned. - - Maintain a record of (Zoom Level -> Result Count). - -3. **Output**: - - The zoom level that yielded the maximum number of results. - -4. **Notes**: - - This process assumes that for a fixed point, the "best" zoom captures the most relevant density without being too broad (losing small entities) or too narrow (missing context). - - Overlaps/Duplicates should be handled by the underlying search function or post-processing if multi-point scanning is used later. - -## 2. Area Scanning (Grid Search) -**Objective**: Scan a larger, defined area (e.g., "Madrid" or "Spain") using the optimal zoom level to ensure comprehensive coverage. - -### Strategy (Planning) -1. **Input**: - - Target Area Boundaries (Polygon/Box). - - Calibrated Zoom Level (from Step 1). - - List of Provinces/Towns (if segmenting by admin regions). - -2. **Grid Generation**: - - Determine the lat/long delta that corresponds to the calibrated zoom level's viewport size. - - Create a grid of search coordinates covering the Target Area. - -3. **Execution & State Management**: - - This is a long-running task. - - **State Store**: Maintain a persistent state (JSON/DB) tracking: - - Queue of pending coordinates. - - Completed coordinates. - - Failed coordinates. - - Process the queue sequentially or in parallel batches. - -4. **Aggregation**: - - Combine all result files. - - Perform global deduplication (by `place_id` or `title` + `address`). diff --git a/packages/ui/docs/locations/enricher.md b/packages/ui/docs/locations/enricher.md deleted file mode 100644 index ee42672f..00000000 --- a/packages/ui/docs/locations/enricher.md +++ /dev/null @@ -1,114 +0,0 @@ -# Enricher System Design - -## Overview - -We are separating the "enrichment" logic (scraping, email finding, etc.) from the core search library (`@polymech/search`) to create a modular, extensible system within the server. This system will support both on-demand discovery (fast initial results + streaming enrichment) and batch processing. - -## Goals - -1. **Decouple:** Move enrichment logic out of `googlemaps.ts`. -2. **Performance:** Allow fast initial search results (meta=false) with lazy loading for enrichment. -3. **Extensibility:** Registry-based system to easily swap or add enrichers (e.g., 'local', 'outsource'). -4. **Streaming:** Centralized streaming hub to emit enrichment updates to the client. - -## Architecture - -### 1. The Enricher Interface - -Each enricher must implement a standard interface. - -```typescript -export interface EnrichmentContext { - userId: string; - // ... potentially other context -} - -export interface IEnricher { - name: string; - type: 'meta' | 'email' | 'phones' | string; - - /** - * Enrich a single location. - * @param location The partial competitor data available - * @param context Execution context - */ - enrich(location: CompetitorFull, context: EnrichmentContext): Promise>; -} -``` - -### 2. Registry - -A simple registry to manage available enrichers. - -```typescript -export class EnricherRegistry { - private static enrichers: Map = new Map(); - - static register(name: string, enricher: IEnricher) { - this.enrichers.set(name, enricher); - } - - static get(name: string): IEnricher | undefined { - return this.enrichers.get(name); - } - - static getAll(): IEnricher[] { - return Array.from(this.enrichers.values()); - } -} -``` - -### 3. Implementation: 'Local' Meta Enricher - -We will port the scraping logic from `search/src/lib/html.ts` to `server/src/products/locations/enrichers/local-meta.ts`. - -* **Logic:** Puppeteer/Axios based scraping. -* **Target:** Updates `raw_data.meta`, and extracts social links/emails to `CompetitorSchemaFull` fields. -* **Adjustments:** Ensure strictly server-side dependencies are used and handle errors gracefully without crashing the stream. - -### 4. Streaming Hub - -A new endpoint `/api/competitors/enrich/stream` (or integrated into existing stream logic) that allows the client to request enrichment for specific items. - -**Request:** - -```json -{ - "place_ids": ["..."], - "enrichers": ["meta"] -} -``` - -**Flow:** - -1. Verify usage/credits. -2. For each `place_id`: - * Load current data. - * Run requested enrichers (concurrently or sequentially). - * Emit `enrichment-update` SSE event with the diff/new data. - * Persist updates to DB. - -## Data Schema Extensions - -We will extend `CompetitorSchemaFull` (via `raw_data` or explicit fields) to hold the enrichment results. - -* `meta`: Object containing scraping results (title, description, og-tags). -* `social`: Standardized social profile links. -* `emails`: Discovered emails. - -## Phasing - -### Phase 1: Meta Enricher & Registry - -* Create `EnricherRegistry`. -* Port `html.ts` to `server/src/products/locations/enrichers/meta.ts`. -* Setup the streaming endpoint for "meta" enrichment. - -### Phase 2: Email Enricher - -* Implement 'email' enricher (likely using existing logic or new providers). - -### Phase 3: Client Integration - -* Update client to fetch search results *without* meta first. -* Trigger enrichment stream for visible/requested items. diff --git a/packages/ui/docs/locations/gadm.md b/packages/ui/docs/locations/gadm.md deleted file mode 100644 index 6d97405c..00000000 --- a/packages/ui/docs/locations/gadm.md +++ /dev/null @@ -1,82 +0,0 @@ -# GADM Integration Documentation - -## Overview - -We use the [GADM (Database of Global Administrative Areas)](https://gadm.org/) as our source of truth for administrative boundaries (GeoJSON). This allows us to perform "Regional Scanning" by defining precise polygons for irregular areas like cities, provinces, and states. - -## Data Structure - -GADM organizes areas hierarchically. - -- **Level 0**: Country (e.g., `ESP` for Spain) -- **Level 1**: Primary subdivision (e.g., "Catalunya" - Region) -- **Level 2**: Secondary subdivision (e.g., "Barcelona" - Province) -- **Level 3+**: Tertiary (e.g., Municipalities) - -Every area has a unique **GID** (GADM ID): - -- `ESP` (Spain) -- `ESP.5_1` (Catalunya) -- `ESP.5.1_1` (Barcelona) - -> **Note**: GADM codes are *not* standard ISO codes. Always rely on **Name Search** to find the correct GID. - ---- - -## API Endpoints - -We expose a set of public endpoints to interface with the local `pygadm` wrapper. - -### 1. Search Regions - -Search for a region by name to find its metadata (GID, Name, Type) or full geometry. - -`GET /api/regions/search` - -| Parameter | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| `query` | string | Yes | Name to search for (e.g., "France", "Paris") | -| `content_level` | int | No | Filter by admin level (e.g., `1` for regions) | -| `geojson` | boolean | No | If `true`, returns full `FeatureCollection` with geometry. | - -**Example:** -`/api/regions/search?query=Catalunya&content_level=1` - -### 2. Get Boundary - -Retrieve the precise GeoJSON boundary for a specific known GID. - -`GET /api/regions/boundary/{id}` - -| Parameter | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| `id` | string | Yes | GADM ID (e.g., `FRA.1_1`) | - -**Response:** -Returns a GeoJSON `FeatureCollection` containing the polygon(s) for that region. - -### 3. Get Sub-Region Names - -List all child regions for a given parent code. Useful for cascading dropdowns. - -`GET /api/regions/names` - -| Parameter | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| `admin` | string | Yes | Parent Admin Code (e.g., `FRA` or `FRA.1_1`) | -| `content_level` | int | Yes | The target level to retrieve (e.g., `2`) | - ---- - -## Mapping Strategy: SerpAPI to GADM - -External data sources like Google Maps (via SerpAPI) often use different standards (ISO-3166-2) than GADM. **Do not try to map by Code.** - -**Recommended Workflow:** - -1. **Extract Name**: Get the administrative name from the external result (e.g., `geo.principalSubdivision` -> "Catalunya"). -2. **Search GADM**: Search for this name using the endpoint. - - `GET /api/regions/search?query=Catalunya` -3. **Filter Results**: - - Match `GID_0` to the known Country Code (e.g., `ESP`) to resolve ambiguity (e.g., "Valencia" exists in Spain and Venezuela). -4. **Get Boundary**: Use the resulting `GID` (e.g., `ESP.5_1`) to fetch the polygon. diff --git a/packages/ui/docs/locations/grid-search-ex.md b/packages/ui/docs/locations/grid-search-ex.md deleted file mode 100644 index 8a8074ad..00000000 --- a/packages/ui/docs/locations/grid-search-ex.md +++ /dev/null @@ -1,268 +0,0 @@ -# Grid Search — Implementation Plan - -## Core Insight - -The grid search is a **GADM tree walk** with a **pluggable iterator**. - -Two distinct phases: - -1. **Enumerate** — walk the GADM tree to the target level → return area names + centers (free, cached) -2. **Search** — plug in an iterator function (Google Maps, email, …) that runs per area (costs credits) - -The user sees the area list first. Only when they confirm, the iterator runs. Results are cached per area — re-walking skips already-searched areas. - ---- - -## Data Flow - -``` -Phase 1: Enumerate (no cost) Phase 2: Search (iterator) -──────────────────────────── ───────────────────────── -region: "Spain, Catalonia" iterator: googleMapsSearch -level: "cities" types: ["machine shop"] - - ┌─ searchRegions("Catalonia") for each area in areas: - │ → GID = "ESP.6_1" (L1) searchGoogleMap( - │ type, - ├─ getRegionNames(admin=GID, contentLevel=3) @center.lat,lon,14z - │ → ["Barcelona", "Terrassa", "Sabadell", …] ) - │ → results[] - ├─ getBoundaryFromGpkg(GID, 3) - │ → GeoJSON per city deduplicate by place_id - │ cache per area GID+type - └─ centroid(bbox) → { lat, lon } - → GridArea[] - - Return to user: "Found 42 cities in Catalonia. - Run location search?" → user confirms → Phase 2 -``` - ---- - -## Level Mapping - -| User Level | GADM | Typical Meaning | -|-------------|------|-----------------------------| -| `provinces` | 1 | State / Province / Region | -| `districts` | 2 | County / District / Kreis | -| `cities` | 3 | Municipality / City | -| `towns` | 4 | Town / Commune | -| `villages` | 5 | Village / Sub-commune | - -> Not all countries have all levels. Function caps at the country's max depth. - ---- - -## API Design - -### Types - -```typescript -// server/src/products/locations/gridsearch-googlemaps.ts - -/** A resolved area from the GADM tree walk */ -export interface GridArea { - name: string; - gid: string; - level: number; - center: { lat: number; lon: number }; -} - -/** Phase 1 result — just the enumerated areas */ -export interface GridEnumerateResult { - region: { name: string; gid: string; level: number }; - areas: GridArea[]; - maxLevelAvailable: number; -} - -/** Phase 2 result — per-area search output */ -export interface GridSearchAreaResult { - area: GridArea; - results: any[]; - cached: boolean; - error?: string; -} - -/** Phase 2 full result */ -export interface GridSearchResult { - region: { name: string; gid: string; level: number }; - areaCount: number; - totalResults: number; - results: any[]; // deduplicated - areas: GridSearchAreaResult[]; - durationMs: number; -} -``` - -### Functions - -```typescript -/** - * Phase 1: Walk GADM tree, enumerate areas at target level. - * No SerpAPI calls, no cost. Results are cached. - */ -export async function gridEnumerate(opts: { - region: string; // "Spain, Catalonia" - level: GridLevel; // 'cities' | 'towns' | number -}): Promise - -/** - * Phase 2: Run Google Maps search on each area. - * This is the iterator — pluggable per search type. - */ -export async function gridSearchGoogleMaps(opts: { - areas: GridArea[]; - types: string[]; - apiKey: string; - bigdata?: { key: string }; - limitPerArea?: number; // default: 20 - zoom?: number; // default: 14 - concurrency?: number; // default: 2 -}): Promise -``` - -### Separation of Concerns - -``` -gridEnumerate() → pure GADM, no cost, cacheable -gridSearchGoogleMaps() → takes areas[], fires SerpAPI, costs credits -``` - -Later iterators can follow the same pattern: -- `gridSearchEmails(areas, opts)` — find emails per area -- `gridSearchEnrich(areas, opts)` — run enrichers per area - ---- - -## Step-by-Step Logic - -### `gridEnumerate()` - -1. Split `region` on comma: `["Spain", "Catalonia"]` -2. Last part = `name`, first = `country` hint -3. `searchRegions({ query: name, country })` → get GID + level -4. Map level label → number (`cities=3`) -5. `getRegionNames({ admin: gid, contentLevel })` → rows with NAME/GID -6. `getBoundaryFromGpkg(gid, contentLevel)` → GeoJSON features -7. Compute bbox centroid per feature → `GridArea[]` - -### `gridSearchGoogleMaps()` - -1. For each area, build `searchCoord: @lat,lon,{zoom}z` -2. For each type in `types[]`: - - `searchGoogleMap(type, apiKey, opts)` → results -3. Merge results per area -4. Deduplicate globally by `place_id` -5. Return `GridSearchResult` - ---- - -## Center Computation (No Turf.js) - -```typescript -function bboxCentroid(feature: any): { lat: number; lon: number } { - let minLat = Infinity, maxLat = -Infinity; - let minLon = Infinity, maxLon = -Infinity; - const walk = (coords: any) => { - if (typeof coords[0] === 'number') { - const [lon, lat] = coords; - if (lat < minLat) minLat = lat; - if (lat > maxLat) maxLat = lat; - if (lon < minLon) minLon = lon; - if (lon > maxLon) maxLon = lon; - return; - } - for (const c of coords) walk(c); - }; - walk(feature.geometry.coordinates); - return { lat: (minLat + maxLat) / 2, lon: (minLon + maxLon) / 2 }; -} -``` - ---- - -## File Structure - -``` -server/src/products/locations/ -├── gridsearch-googlemaps.ts # [NEW] gridEnumerate + gridSearchGoogleMaps -├── __tests__/ -│ └── gridsearch-googlemaps.e2e.test.ts # [NEW] E2E tests -``` - ---- - -## Test Plan - -```typescript -describe('Grid Search', () => { - - describe('Phase 1 — Enumerate', () => { - it('enumerates Catalonia cities', async () => { - const result = await gridEnumerate({ - region: 'Spain, Catalonia', - level: 'cities', - }); - expect(result.areas.length).toBeGreaterThan(0); - expect(result.areas[0].center.lat).toBeTypeOf('number'); - // No SerpAPI calls, no cost - }); - - it('enumerates Sachsen districts', async () => { - const result = await gridEnumerate({ - region: 'Germany, Sachsen', - level: 'districts', - }); - expect(result.areas.length).toBe(13); - }); - }); - - describe('Phase 2 — Google Maps Search', () => { - it('searches machine shops in 2 Catalonia cities', async () => { - const enumResult = await gridEnumerate({ - region: 'Spain, Catalonia', - level: 'cities', - }); - - // Only search first 2 areas to keep test cheap - const result = await gridSearchGoogleMaps({ - areas: enumResult.areas.slice(0, 2), - types: ['machine shop'], - apiKey: config.serpapi.key, - bigdata: config.bigdata, - limitPerArea: 5, - concurrency: 1, - }); - - expect(result.totalResults).toBeGreaterThan(0); - expect(result.areas[0].area.name).toBeDefined(); - }, 120_000); - }); -}); -``` - -### NPM Script - -```json -"test:products:locations:gridsearch:googlemaps": "vitest run src/products/locations/__tests__/gridsearch-googlemaps.e2e.test.ts" -``` - ---- - -## Caching Strategy - -| What | Cache Key | Storage | -|------|-----------|---------| -| Tree enumerate | `grid_enum_{gid}_{level}` | GADM file cache | -| Google Maps search | `grid_search_{gid}_{type}_{zoom}` | Supabase `place_searches` | -| Area boundaries | `boundary_{gid}` | GADM file cache (already cached) | - ---- - -## Future Iterators (Not Phase 1) - -- `gridSearchEmails(areas)` — find emails for businesses found in each area -- `gridSearchEnrich(areas)` — run meta/social enrichers per area -- PgBoss campaign integration — one child job per area -- SSE streaming — live progress as each area completes -- Cost estimation pre-flight — `areaCount × costPerSearch` diff --git a/packages/ui/docs/locations/gridsearch.md b/packages/ui/docs/locations/gridsearch.md deleted file mode 100644 index 259cdf30..00000000 --- a/packages/ui/docs/locations/gridsearch.md +++ /dev/null @@ -1,163 +0,0 @@ -# Grid Search / Regional Scanning Documentation - -## Overview - -The Grid Search (or Regional Scanning) feature automates the discovery of leads across large, irregular geographic areas (e.g., entire cities, provinces, or countries). Instead of manual point searches, users select a defined administrative region, and the system intelligently decomposes it into a grid of optimal search points. - -This functionality relies on a microservice architecture where **GADM** (Global Administrative Areas) data provides high-fidelity GeoJSON boundaries for exclusion/inclusion logic. - ---- - -## Conceptual Architecture - -### 1. Region Selection (Client) - -The user select a target region (e.g., "Île-de-France, France"). The client fetches the corresponding boundary polygon from the GADM microservice (Admin Level 1/2). - -### 2. Grid Decomposition (Server/Client) - -The system calculates a "Search Grid" overlaying the target polygon. - -- **Viewport Normalization**: A single API search at Zoom Level 15 covers roughly a 2-5km radius. -- **Bounding Box**: A rectangular grid is generated covering the polygon's extents. -- **Point-in-Polygon Filtering**: Grid centers falling *outside* the actual administrative boundary (e.g., ocean, neighboring states) are discarded using spatial analysis libraries (e.g., `Turf.js`). - -### 3. Campaign Orchestration (Server) - -The resulting set of valid coordinates (e.g., 450 points) is submitted as a **"Scan Campaign"**. - -- **Batching**: The server does NOT run 450 searches instantly. It uses `PgBoss` to queue them as individual jobs. -- **Concurrency**: Jobs are processed with strict rate-limiting to respect SerpAPI quotas. -- **Deduplication**: Results from overlapping grid circles are merged by `place_id`. - ---- - -## Workflow Implementation - -### Step 1: User Selects Region - -User interactions with the new "Region Search" UI: - -1. **Search**: "California" -2. **Dropdown**: Selects "California, USA (State/Province)" -3. **Preview**: Map validates the polygon overlay. - -### Step 2: Grid Generation Status - -Pre-flight check displayed to user: - -- **Total Area**: 423,970 km² -- **Grid Density**: High (Zoom 15) -- **Estimated Points**: ~8,500 scans (Warn: Expensive!) -- **Cost**: 8,500 Credits -- **Action**: "Confirm & Start Campaign" - -### Step 3: Campaign Execution - -Server receives payload: - -```json -{ - "regionId": "USA.5_1", - "query": "Plumbers", - "gridConfig": { "zoom": 15, "overlap": 0.2 } -} -``` - -Server decomposes to jobs `[Job_1, Job_2, ... Job_8500]`. - -### Step 4: Live Updates - -The existing SSE stream (`stream-sse`) adapts to listen for Campaign Events, updating a global progress bar: - -- "Scanned 120/8500 sectors..." -- "Found 45 new leads..." - ---- - -## Implementation TODO List - -### Server-Side (`test/server`) - -- [x] **GADM Integration Endpoint**: - - [x] Create route `GET /api/regions/search?q={name}` to proxy requests to the GADM microservice or query local PostGIS. - - [x] Create route `GET /api/regions/boundary/{gadm_id}` to retrieve full GeoJSON. - - [x] Create route `GET /api/regions/names?admin={code}` to fetch sub-region names. -- [ ] **Grid Logic**: - - Install `@turf/turf` for geospatial operations. - - Implement `generateGrid(boundaryFeature, zoomLevel)` function: - - Calculate `bbox`. - - Generate point grid. - - Filter `pointsWithinPolygon`. -- [ ] **Campaign Manager**: - - Create `CampaignsProduct` or extend `LocationsProduct`. - - New Job Type: `REGION_SCAN_PARENT` (decomposes into child jobs). - - New Job Type: `REGION_SCAN_CHILD` (actual search). -- [ ] **Job Queue Optimization**: - - Ensure `PgBoss` allows huge batch insertions (thousands of jobs). - - Implement "Campaign Cancellation" (kill switch for all child jobs). - -### Client-Side (`test/client`) - -- [ ] **Region Picker UI**: - - New Autocomplete component fetching from `/api/regions/search`. -- [ ] **Map Visualization**: - - Render the GeoJSON `Polygon` on MapLibre. - - Render the calculated `Point` grid pre-flight (allow user to manually deselect points?). -- [ ] **Campaign Dashboard**: - - New View: "Active Scans". - - Progress bars per campaign. - - "Pause/Resume" controls. -- [ ] **Result Merging**: - - Ensure the client DataGrid can handle streaming results effectively from potentially thousands of searches (Virtualization required). - ---- - -## Existing Endpoint Reference - -*(Ref. `src/products/locations/index.ts`)* - -The current `LocationsProduct` is well-poised to be the parent of this logic. - -- **`handleStreamGet`**: Can be adapted to accept a `campaignId` instead of a single `location`. -- **`handleStreamEmail`**: Shows the pattern for batch processing (accepting arrays of IDs). We can replicate this "Scatter-Gather" pattern for the Region Scan. - -### Proposed GeoJSON Microservice Interface - -We assume the existence of an internal service (or creating a dedicated module) exposing: - -- `GET /gadm/v1/search?text=...` -> Returns lightweight metadata (ID, Name, Level). -- `GET /gadm/v1/feature/{id}` -> Returns heavy GeoJSON Geometry. - ---- - -## 4. Potential Data Enrichments - -To increase the value of harvested locations, the following layers can be overlaid or merged with the search results: - -### Demographics & Population - -- **WorldPop**: High-resolution raster data for estimating the catchment population of a specific business location. - -- **Census Data**: (US Census / Eurostat) Admin-level statistics on income, age, and household size to score "Market Viability". - -### Firmographics & Business Intel - -- **OpenCorporates**: Verify legal entity status and official registration dates. - -- **LinkedIn Organization API**: Enrich with employee count, industry tags, and recent growth signals. -- **Clearbit / Apollo.io**: Deep profile matching to find technographics (what software they use) and key decision-maker contacts. - -### Environmental & Infrastructure - -- **OpenStreetMap (OSM)**: Calculate "Footfall Potential" by analyzing proximity to transit hubs, parking, and density of other retail POIs. - -- **WalkScore / TransitScore**: Rate the accessibility of consumer-facing businesses. - -### Industry Specifics - -- **TripAdvisor / Yelp**: Cross-reference hospitality ratings to find discrepancies or opportunities (e.g., highly rated on Google, poorly rated on Yelp). - -- **Plastics Industry Databases**: (Specific to Polymech) Cross-referencing registered recyclers lists provided by regional environmental agencies. - - diff --git a/packages/ui/docs/places/gadm.md b/packages/ui/docs/places/gadm.md deleted file mode 100644 index 2f3dd6d1..00000000 --- a/packages/ui/docs/places/gadm.md +++ /dev/null @@ -1,54 +0,0 @@ -# GADM Picker Implementation details - -This document covers the architectural and interaction details of the global bounds and region picker system. -The system connects an interactive `` frontend map down to a PostGIS + Martin + PMTiles mapping backend. - -## Architecture & Paths - -- **Main Component**: [`GadmPicker.tsx`](../../src/modules/places/gadm-picker/GadmPicker.tsx) -- **Local Searches & IO**: [`client-searches.ts`](../../src/modules/places/gadm-picker/client-searches.ts) -- **Server Application (Vite / Express)**: [`server.ts`](../../packages/gadm/server.ts) - -## API Endpoints (`/api/gadm/*`) - -The picker orchestrates several custom endpoints for real-time geographic data validation, mostly routed through Express in `packages/gadm/server.ts`: - -- **`GET /search?q={query}&level={level}`** - Searches the PostGIS database `gadm` view for any names matching the search vector. Often utilizes Redis caching to speed up autocomplete responses. -- **`GET /hierarchy?lat={lat}&lng={lng}`** - Triggers a point-based intersection against the `gadm` multi-polygons (`ST_Intersects`). Returns the full hierarchy (Level 0 through 5) containing the given coordinate. -- **`GET /boundary?gid={gid}&targetLevel={level}&enrich={bool}`** - Returns the exact geographic boundaries of a target region as a GeoJSON FeatureCollection. - - To maintain UI performance on large sets, queries the requested `targetLevel` limit to simplify rendering visually. - - Can optionally `enrich` the returned properties with Population and Area sizing dynamically from the PG backend. - - GeoJSON responses are statically cached locally to `packages/gadm/data/boundaries/` to ensure lightning-fast subsequent fetches. - -## The Map Inspector - -The map view uses MapLibre GL JS pointing to a local Martin vector tile server serving `.pmtiles`. -- **Point Queries**: Clicking anywhere on the unselected tiles translates the event into a `lat, lng` inspection. -- **Hierarchy Render**: This invokes `/hierarchy` and generates a list of administrative boundaries encompassing that specific point (from Nation down to County). -- **Highlighting**: Hovering over any inferred hierarchy option loads its bounding box dynamically (`gadm-picker-highlight`) to review before formally "adding". - -## Selection Lifecycle - -The selection state `selectedRegions` tracks picked regions across the UI. Due to API speeds and GeoJSON size considerations, the component features a highly customized, safe, interruptible multi-selection architecture: - -- **Single Select (Default)** - Clicking an autocomplete result or clicking to inspect on the map triggers a single-select wipe. This safely terminates any currently loading polygons, cancels queued network requests, and instantly drops all existing items from the array to maintain focus down to a single element. - -- **Multi-Select Queue (`ctrl + click` / `⌘ + click`)** - If the `ctrl` key is held either on the autocomplete result, the inspector UI "Add" button, or on raw Map inspection clicks, the interactions skip cancellation logic. They are placed into a `queuedInspectionsRef` Set. Network resolutions occur concurrently and stack natively into the interface. - -- **Import / Export Portability** - The `` exposes IO tools to manage large or heavily tailored multi-select combinations: - - **Copy Config**: Translates the active GIDs and target levels directly to the local clipboard. - - **Export JSON**: Creates a local Blob URL and downloads the `selectedRegions` metadata explicitly (excludes raw poly-data to maintain strict minimalist file sizes). - - **Import JSON**: Triggers a hidden file input ``. Firing an import automatically wipes the active UI state and iteratively pushes all imported regions into the `ctrl+click` style high-speed multi-select queue to render perfectly. - -## Boundaries (`setGeojsons`) - -After an entity enters `selectedRegions`, its exact representation is rendered securely on the map with the ID layer `gadm-picker-features`. -- A background `useEffect` strictly manages sync loops formatting the multiple separate boundaries into a unified `FeatureCollection`, updating the MapLibre source in real-time. -- Regions feature small layout indicators (e.g. `L0`, `L2`) mapping directly to the `targetLevel` rendering logic determining boundary complexity. - diff --git a/packages/ui/docs/products-crud.md b/packages/ui/docs/products-crud.md new file mode 100644 index 00000000..ba6013ce --- /dev/null +++ b/packages/ui/docs/products-crud.md @@ -0,0 +1,52 @@ +# Products CRUD Implementation Plan + +Based on the simplified approach, **Products are separate from the core `resource_acl` ecosystem**. Products will have their own dedicated table and CRUD operations. They integrate with the ACL system purely by convention (the product's `slug` is used as the `resource_id` in `resource_acl` for product-level rules). + +## 1. Database Schema +We need to modify the existing `products` table (or create it if it's currently unused) to support the required fields: `id` (UUID), `name`, `slug`, and `settings` (JSONB). + +- [ ] **Migration / SQL Update**: + - Convert `id` to `UUID` (or ensure new table uses UUID). + - Add `settings` `jsonb` column for flags like `{ enabled: true, ... }`. + - Retain `slug` (unique) to use as the join key for ACL mapping. + - Retain `name` and `description`. + +```sql +create table if not exists public.products ( + id uuid not null default gen_random_uuid (), + name text not null, + slug text not null, + description text null, + settings jsonb null default '{"enabled": true}'::jsonb, + created_at timestamp with time zone not null default now(), + updated_at timestamp with time zone not null default now(), + constraint products_pkey primary key (id), + constraint products_slug_key unique (slug) +); +``` + +## 2. API Endpoints (`/api/admin/products`) +Since Products are independent of the ACL internals, they get their own dedicated admin endpoints rather than being bundled under `/api/admin/acl`. + +- [ ] **Zod Schemas** (`server/src/endpoints/admin-products.ts`) + - `ProductCreateSchema`: name, slug, description, settings. + - `ProductUpdateSchema`: name, description, settings (maybe allow slug updates with a warning about ACL disconnections). + +- [ ] **Handlers** + - `GET /api/admin/products`: List all products. + - `GET /api/admin/products/:slug`: Get details for a single product. + - `POST /api/admin/products`: Insert new product. (Slug can be auto-generated from name if omitted). + - `PUT /api/admin/products/:slug`: Update the product row. + - `DELETE /api/admin/products/:slug`: Delete the product. + +## 3. Integration with ACLs +The only touchpoint between the `products` table and the `resource_acl` table is the **slug**. + +- [ ] When fetching a User's product-level permissions, we query `resource_acl` where `resource_type = 'product-acl'` and `resource_id = product.slug`. +- [ ] Optionally, implement a cleanup hook: if a product is deleted via `DELETE /api/admin/products/:slug`, fire an event to delete matching `product-acl` rows from `resource_acl`. +- [ ] During the OpenAPI sync (which we discussed for Endpoints), endpoints will match the `products.slug` to populate their `meta.product`. + +## 4. Server Integration +- [ ] Build the routes in `server/src/endpoints/admin-products.ts`. +- [ ] Protect all routes with the `Admin()` decorator. +- [ ] Register the routes in the main server router. diff --git a/packages/ui/docs/products.md b/packages/ui/docs/products.md new file mode 100644 index 00000000..2650e8e0 --- /dev/null +++ b/packages/ui/docs/products.md @@ -0,0 +1,69 @@ +create table public.products ( + id bigint generated by default as identity not null, + name text not null, + slug text not null, + description text null, + price numeric(10, 2) not null default 0, + variants jsonb null default '[]'::jsonb, + created_at timestamp with time zone not null default timezone ('utc'::text, now()), + updated_at timestamp with time zone not null default timezone ('utc'::text, now()), + constraint products_pkey primary key (id), + constraint products_slug_key unique (slug) +) TABLESPACE pg_default; + + +create table public.product_variants ( + id bigint generated by default as identity not null, + product_id bigint not null, + name text not null, + price numeric(10, 2) not null default 0, + created_at timestamp with time zone not null default timezone ('utc'::text, now()), + updated_at timestamp with time zone not null default timezone ('utc'::text, now()), + constraint product_variants_pkey primary key (id), + constraint product_variants_product_id_fkey foreign key (product_id) references products(id) on delete cascade +) TABLESPACE pg_default; + + +=================================== + + +//current map +create table public.resource_acl ( + id uuid not null default gen_random_uuid (), + resource_type text not null, + resource_id text not null, + resource_owner_id uuid null, + user_id uuid null, + group_name text null, + permissions text[] not null default '{}'::text[], + path text null default '/'::text, + meta jsonb null default '{}'::jsonb, + log jsonb null default '{}'::jsonb, + created_at timestamp with time zone null default now(), + updated_at timestamp with time zone null default now(), + constraint resource_acl_pkey primary key (id), + constraint resource_acl_resource_owner_id_fkey foreign KEY (resource_owner_id) references auth.users (id), + constraint resource_acl_user_id_fkey foreign KEY (user_id) references auth.users (id) on delete CASCADE, + constraint check_grantee check ( + ( + ( + (user_id is not null) + and (group_name is null) + ) + or ( + (user_id is null) + and (group_name is not null) + ) + or ( + (user_id is null) + and (group_name is null) + ) + ) + ) +) TABLESPACE pg_default; + +create index IF not exists idx_resource_acl_lookup on public.resource_acl using btree (resource_type, resource_id) TABLESPACE pg_default; + +create index IF not exists idx_resource_acl_user on public.resource_acl using btree (user_id) TABLESPACE pg_default; + +create index IF not exists idx_resource_acl_owner on public.resource_acl using btree (resource_owner_id) TABLESPACE pg_default; \ No newline at end of file diff --git a/packages/ui/shared/src/competitors/schemas.ts b/packages/ui/shared/src/competitors/schemas.ts index 150a8ec8..7f995fd8 100644 --- a/packages/ui/shared/src/competitors/schemas.ts +++ b/packages/ui/shared/src/competitors/schemas.ts @@ -6,7 +6,7 @@ import { extendZodWithOpenApi } from '@hono/zod-openapi'; extendZodWithOpenApi(z); -export const CompetitorSchema = z.object({ +export const PlaceSchema = z.object({ place_id: z.string(), title: z.string(), description: z.string().optional().nullable(), @@ -209,7 +209,7 @@ export const GoogleMediaSchema = z.object({ }) // Raw data schema -export const LocationSchema = z.object({ +export const PlaceRawSchema = z.object({ position: z.number(), rating: z.number(), reviews: z.number(), @@ -243,7 +243,7 @@ export const LocationSchema = z.object({ }).partial() // Main CompetitorSchemaFull -export const CompetitorSchemaFull = z.object({ +export const PlaceSchemaFull = z.object({ place_id: z.string(), title: z.string(), address: z.string().optional().nullable(), @@ -253,7 +253,7 @@ export const CompetitorSchemaFull = z.object({ operating_hours: OperatingHoursSchema.optional().nullable(), thumbnail: z.string().optional().nullable(), types: z.array(z.string()).optional().nullable(), - raw_data: LocationSchema.optional().nullable(), + raw_data: PlaceRawSchema.optional().nullable(), sites: z.array(z.object({ name: z.string(), url: z.string(), @@ -266,27 +266,27 @@ export const CompetitorSchemaFull = z.object({ }) -export const CompetitorResponseSchema = z.object({ +export const PlaceResponseSchema = z.object({ message: z.string(), - data: z.array(CompetitorSchemaFull).optional(), + data: z.array(PlaceSchemaFull).optional(), }) -export const CompetitorDetailResponseSchema = z.object({ +export const PlaceDetailResponseSchema = z.object({ message: z.string(), - data: CompetitorSchemaFull.optional(), + data: PlaceSchemaFull.optional(), }) -export type Competitor = z.infer; -export type CompetitorResponse = z.infer; -export type CompetitorDetailResponse = z.infer; -export type CompetitorFull = z.infer; +export type Competitor = z.infer; +export type PlaceResponse = z.infer; +export type PlaceDetailResponse = z.infer; +export type PlaceFull = z.infer; export type OptionsSchemaMeta = Record let schemaMap: ZodMetaMap; -export const CompetitorRequestSchemaMap = () => { +export const PlaceRequestSchemaMap = () => { schemaMap = ZodMetaMap.create() schemaMap.add( 'location', @@ -332,8 +332,8 @@ export const CompetitorRequestSchemaMap = () => { return schemaMap; } -export const CompetitorRequestSchema = CompetitorRequestSchemaMap().root() as any; -export const CompetitorUISchema = CompetitorRequestSchemaMap().getUISchema(); -export type LocationType = z.infer; -export type CompetitorRequest = z.infer; +export const PlaceRequestSchema = PlaceRequestSchemaMap().root() as any; +export const PlaceUISchema = PlaceRequestSchemaMap().getUISchema(); +export type LocationType = z.infer; +export type PlaceRequest = z.infer; diff --git a/packages/ui/src/components/widgets/CompetitorsMapWidget.tsx b/packages/ui/src/components/widgets/CompetitorsMapWidget.tsx index a95323b2..0059192c 100644 --- a/packages/ui/src/components/widgets/CompetitorsMapWidget.tsx +++ b/packages/ui/src/components/widgets/CompetitorsMapWidget.tsx @@ -1,10 +1,12 @@ -import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; -import { CompetitorsMapView } from '@/modules/places/CompetitorsMapView'; +import React, { useState, useCallback, useMemo, forwardRef, useImperativeHandle, useRef } from 'react'; +import { PlacesMapView } from '@/modules/places/PlacesMapView'; import { LocationDetailView } from '@/modules/places/LocationDetail'; import { T } from '@/i18n'; -import { Map as MapIcon, Loader2, Search, GripVertical } from 'lucide-react'; -import { type CompetitorFull } from '@polymech/shared'; -import { fetchPlacesGridSearchById } from '@/modules/places/client-gridsearch'; +import { Loader2, GripVertical } from 'lucide-react'; +import { usePlacesMapData, type PlacesMapWidgetHandle } from './useCompetitorsMapData'; + +// Re-export the handle type so consumers can import from the widget file +export type { PlacesMapWidgetHandle as CompetitorsMapWidgetHandle } from './useCompetitorsMapData'; interface CompetitorsMapWidgetProps { isEditMode?: boolean; @@ -25,9 +27,12 @@ interface CompetitorsMapWidgetProps { showLocations?: boolean; posterMode?: boolean; enableLocationDetails?: boolean; + maxHeight?: string; + /** Fired once the widget is ready with its imperative API handle. */ + onReady?: (handle: PlacesMapWidgetHandle) => void; } -const CompetitorsMapWidget: React.FC = ({ +const CompetitorsMapWidget = forwardRef(({ isEditMode = false, jobId, enableSimulator = true, @@ -41,17 +46,31 @@ const CompetitorsMapWidget: React.FC = ({ showLocations = true, posterMode = false, enableLocationDetails = true, -}) => { - const [competitors, setCompetitors] = useState([]); - const [simulatorSettings, setSimulatorSettings] = useState(null); - const [fetchedRegions, setFetchedRegions] = useState([]); - const [loading, setLoading] = useState(false); - const containerRef = useRef(null); + maxHeight = '60vh', + onReady, +}, ref) => { + // ----------------------------------------------------------------------- + // Data layer (hook) + // ----------------------------------------------------------------------- + const { + competitors, + loading, + initialCenter, + finalGadmRegions, + initialSimulatorSettings, + handle, + handleMapReady: hookMapReady, + } = usePlacesMapData({ jobId, targetLocation }); - // Location detail sidebar state + useImperativeHandle(ref, () => handle, [handle]); + + // ----------------------------------------------------------------------- + // UI-only state: selection sidebar + // ----------------------------------------------------------------------- const [selectedPlaceId, setSelectedPlaceId] = useState(null); const [showDetails, setShowDetails] = useState(false); const [sidebarWidth, setSidebarWidth] = useState(340); + const containerRef = useRef(null); const activeCompetitor = useMemo(() => { if (!selectedPlaceId) return null; @@ -83,91 +102,36 @@ const CompetitorsMapWidget: React.FC = ({ window.addEventListener('mouseup', onUp); }, [sidebarWidth]); - // Prioritize the jobId from the specialized picker over the direct prop - const effectiveJobId = targetLocation?.jobId || jobId; + // ----------------------------------------------------------------------- + // Map ready: bridge hook + onReady callback + // ----------------------------------------------------------------------- + const onReadyRef = useRef(onReady); + onReadyRef.current = onReady; - // Initial map values from the picker data - const initialCenter = targetLocation?.lat && targetLocation?.lng - ? { lat: targetLocation.lat, lng: targetLocation.lng } - : (targetLocation?.center ? { lat: targetLocation.center.lat, lng: targetLocation.center.lng } : undefined); + const handleMapReady = useCallback((m: import('maplibre-gl').Map) => { + hookMapReady(m); + onReadyRef.current?.(handle); + }, [hookMapReady, handle]); - // GADM regions from the picker - const initialGadmRegions = (targetLocation?.gadmRegions?.length ? targetLocation.gadmRegions : fetchedRegions).map((r: any) => ({ - gid: r.gid, - name: r.gadmName || r.name, - level: r.level !== undefined ? (typeof r.level === 'string' ? parseInt(r.level.replace(/\D/g, '')) : r.level) : 0 - })); + // ----------------------------------------------------------------------- + // Stubs + // ----------------------------------------------------------------------- + const dummyEnrich = useCallback(async () => { }, []); + const handleMapCenterUpdate = useCallback(() => { }, []); - const finalGadmRegions = initialGadmRegions.length > 0 - ? initialGadmRegions - : (targetLocation?.gid ? [{ gid: targetLocation.gid, name: targetLocation.label || targetLocation.name, level: targetLocation.level }] : undefined); - - const initialSimulatorSettings = targetLocation?.simulatorSettings || simulatorSettings; - - // Fetch real data when a Job ID is available - useEffect(() => { - if (!effectiveJobId) { - setCompetitors([]); - setSimulatorSettings(null); - return; - } - - if (targetLocation?.jobId === effectiveJobId && targetLocation?.competitors && targetLocation?.simulatorSettings) { - setCompetitors(targetLocation.competitors); - setSimulatorSettings(targetLocation.simulatorSettings); - return; - } - - let isMounted = true; - (async () => { - setLoading(true); - try { - const job = await fetchPlacesGridSearchById(effectiveJobId); - const data = job?.data || job; - - if (isMounted && data) { - const enrichResults = data.result?.enrichResults; - if (Array.isArray(enrichResults)) { - setCompetitors(enrichResults); - } else if (Array.isArray(data.places)) { - setCompetitors(data.places); - } - - if (data.request?.guided?.settings) { - setSimulatorSettings(data.request.guided.settings); - } - - if (Array.isArray(data.areas)) { - setFetchedRegions(data.areas); - } else if (data.request?.guided?.areas) { - setFetchedRegions(data.request.guided.areas); - } - } - } catch (err) { - console.error('Failed to load search job points:', err); - if (isMounted) setCompetitors([]); - } finally { - if (isMounted) setLoading(false); - } - })(); - - return () => { isMounted = false; }; - }, [effectiveJobId]); - - const dummyEnrich = async () => { }; - const handleMapCenterUpdate = () => { }; + const effectiveMaxHeight = showDetails && activeCompetitor ? '60vh' : maxHeight; return ( -
+
{loading ? (

Loading map data...

) : ( -
-
- +
+ = ({ initialCenter={initialCenter} initialGadmRegions={finalGadmRegions} initialSimulatorSettings={initialSimulatorSettings} - competitors={competitors} + places={competitors} onMapCenterUpdate={handleMapCenterUpdate} enrich={dummyEnrich} isEnriching={false} @@ -191,20 +155,21 @@ const CompetitorsMapWidget: React.FC = ({ isPosterMode={posterMode} selectedPlaceId={selectedPlaceId} onSelectPlace={enableLocationDetails ? handleSelectPlace : undefined} + onMapReady={handleMapReady} />
{/* Location Detail Sidebar — mirrors GridSearchResults pattern */} {enableLocationDetails && showDetails && activeCompetitor && ( -
+
-
= ({ )}
); -}; +}); + +CompetitorsMapWidget.displayName = 'CompetitorsMapWidget'; export default CompetitorsMapWidget; diff --git a/packages/ui/src/components/widgets/location-picker/SearchesTab.tsx b/packages/ui/src/components/widgets/location-picker/SearchesTab.tsx index 798472d0..4667c726 100644 --- a/packages/ui/src/components/widgets/location-picker/SearchesTab.tsx +++ b/packages/ui/src/components/widgets/location-picker/SearchesTab.tsx @@ -23,8 +23,6 @@ export default function SearchesTab({ onUpdate, initialSelection, currentSelecti // Local override for immediate feedback while fetching metadata const [localSelectedId, setLocalSelectedId] = useState(null); const activeJobId = localSelectedId || currentSelection?.jobId || initialSelection?.jobId; - - // console.log('SearchesTab render', { activeJobId, currentSelectionId: currentSelection?.jobId, initialSelectionId: initialSelection?.jobId }); useEffect(() => { const load = async () => { @@ -59,7 +57,7 @@ export default function SearchesTab({ onUpdate, initialSelection, currentSelecti // Minimal fetch: just get the job search summary/request for label and center const fullJob = await fetchPlacesGridSearchById(job.id); const data = fullJob?.data || fullJob; - + // Try to find a center point if available const lat = data?.request?.enumerate?.lat || data?.request?.search?.lat || data?.query?.lat; const lng = data?.request?.enumerate?.lng || data?.request?.search?.lng || data?.query?.lng; @@ -71,7 +69,7 @@ export default function SearchesTab({ onUpdate, initialSelection, currentSelecti lat: lat ? parseFloat(lat) : undefined, lng: lng ? parseFloat(lng) : undefined }; - + // console.log('SearchesTab updating parent', selection); onUpdate(selection); } catch (err) { diff --git a/packages/ui/src/components/widgets/useCompetitorsMapData.ts b/packages/ui/src/components/widgets/useCompetitorsMapData.ts new file mode 100644 index 00000000..d51fa60d --- /dev/null +++ b/packages/ui/src/components/widgets/useCompetitorsMapData.ts @@ -0,0 +1,210 @@ +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import { PlaceFull } from '@polymech/shared'; +import { fetchPlacesGridSearchById } from '@/modules/places/client-gridsearch'; +import maplibregl from 'maplibre-gl'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- +export interface PlacesMapWidgetHandle { + /** Zoom the map to a specific level (0-22). Optionally animate. */ + zoomTo: (zoom: number, options?: { duration?: number }) => void; + /** Center (and optionally zoom) the map on a lat/lng coordinate. */ + centerTo: (lat: number, lng: number, options?: { zoom?: number; duration?: number }) => void; + /** Replace the displayed location markers with a new set. */ + setLocations: (locations: PlaceFull[]) => void; + /** Replace the GADM regions displayed on the map. */ + setRegions: (regions: Array<{ gid: string; name: string; level: number }>) => void; + /** Update the grid-search simulator settings. */ + setSimSettings: (settings: Record) => void; + /** Direct access to the underlying MapLibre instance (may be null). */ + getMap: () => maplibregl.Map | null; +} + +export interface GadmRegion { + gid: string; + name: string; + level: number; +} + +interface UsePlacesMapDataOptions { + jobId?: string; + targetLocation?: any; +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- +export function usePlacesMapData({ jobId, targetLocation }: UsePlacesMapDataOptions) { + const [competitors, setCompetitors] = useState([]); + const [simulatorSettings, setSimulatorSettings] = useState(null); + const [fetchedRegions, setFetchedRegions] = useState([]); + const [loading, setLoading] = useState(false); + + const mapRef = useRef(null); + const hasFittedRef = useRef(false); + + // Imperative region override — when set, takes priority over fetched/picker regions + const [imperativeRegions, setImperativeRegions] = useState(null); + + // ----------------------------------------------------------------------- + // Derived: effective job ID, initial center, GADM regions, sim settings + // ----------------------------------------------------------------------- + const effectiveJobId = targetLocation?.jobId || jobId; + + const pickerLat = targetLocation?.lat ?? targetLocation?.center?.lat; + const pickerLng = targetLocation?.lng ?? targetLocation?.center?.lng; + const initialCenter = useMemo( + () => (pickerLat && pickerLng ? { lat: pickerLat, lng: pickerLng } : undefined), + [pickerLat, pickerLng], + ); + + // GADM regions: imperative override > picker > fetched + const baseRegions = imperativeRegions ?? (targetLocation?.gadmRegions?.length ? targetLocation.gadmRegions : fetchedRegions); + const normalizedGadmRegions: GadmRegion[] = baseRegions.map((r: any) => ({ + gid: r.gid, + name: r.gadmName || r.name, + level: r.level !== undefined ? (typeof r.level === 'string' ? parseInt(r.level.replace(/\D/g, '')) : r.level) : 0, + })); + + const finalGadmRegions = normalizedGadmRegions.length > 0 + ? normalizedGadmRegions + : targetLocation?.gid + ? [{ gid: targetLocation.gid, name: targetLocation.label || targetLocation.name, level: targetLocation.level }] + : undefined; + + const initialSimulatorSettings = targetLocation?.simulatorSettings || simulatorSettings; + + // ----------------------------------------------------------------------- + // Fetch data when a job ID is available + // ----------------------------------------------------------------------- + useEffect(() => { + if (!effectiveJobId) { + setCompetitors([]); + setSimulatorSettings(null); + return; + } + + // Short-circuit when the picker already carries the full payload + if (targetLocation?.jobId === effectiveJobId && targetLocation?.competitors && targetLocation?.simulatorSettings) { + setCompetitors(targetLocation.competitors); + setSimulatorSettings(targetLocation.simulatorSettings); + return; + } + + let isMounted = true; + (async () => { + setLoading(true); + try { + const job = await fetchPlacesGridSearchById(effectiveJobId); + const data = job?.data || job; + + if (isMounted && data) { + const enrichResults = data.result?.enrichResults; + if (Array.isArray(enrichResults)) { + setCompetitors(enrichResults); + } else if (Array.isArray(data.places)) { + setCompetitors(data.places); + } + + if (data.request?.guided?.settings) { + setSimulatorSettings(data.request.guided.settings); + } + + if (Array.isArray(data.areas)) { + setFetchedRegions(data.areas); + } else if (data.request?.guided?.areas) { + setFetchedRegions(data.request.guided.areas); + } + } + } catch (err) { + console.error('Failed to load search job points:', err); + if (isMounted) setCompetitors([]); + } finally { + if (isMounted) setLoading(false); + } + })(); + + return () => { isMounted = false; }; + }, [effectiveJobId]); + + // ----------------------------------------------------------------------- + // Auto-fit helpers + // ----------------------------------------------------------------------- + const competitorsRef = useRef(competitors); + competitorsRef.current = competitors; + + const fitToPlaces = useCallback((m: maplibregl.Map, locs: PlaceFull[]) => { + if (hasFittedRef.current || locs.length === 0) return; + const bounds = new maplibregl.LngLatBounds(); + let hasPoints = false; + for (const c of locs) { + const lat = (c as any).gps_coordinates?.latitude ?? (c as any).raw_data?.geo?.latitude ?? (c as any).lat; + const lon = (c as any).gps_coordinates?.longitude ?? (c as any).raw_data?.geo?.longitude ?? (c as any).lon; + if (lat != null && lon != null) { bounds.extend([Number(lon), Number(lat)]); hasPoints = true; } + } + if (hasPoints && !bounds.isEmpty()) { + m.fitBounds(bounds, { padding: 50, maxZoom: 9, duration: 1000 }); + hasFittedRef.current = true; + } + }, []); + + // Case 2: Data arrives after map is ready + useEffect(() => { + if (mapRef.current && competitors.length > 0 && !hasFittedRef.current) { + fitToPlaces(mapRef.current, competitors); + } + }, [competitors, fitToPlaces]); + + // ----------------------------------------------------------------------- + // Imperative API handle (stable — never changes identity) + // ----------------------------------------------------------------------- + const handle: PlacesMapWidgetHandle = useMemo(() => ({ + zoomTo(zoom, options) { + mapRef.current?.zoomTo(zoom, { duration: options?.duration ?? 500 }); + }, + centerTo(lat, lng, options) { + if (!mapRef.current) return; + mapRef.current.flyTo({ + center: [lng, lat], + zoom: options?.zoom ?? mapRef.current.getZoom(), + duration: options?.duration ?? 1000, + }); + }, + setLocations(locations) { + hasFittedRef.current = false; // allow re-fit + setCompetitors(locations); + }, + setRegions(regions) { + setImperativeRegions(regions); + }, + setSimSettings(settings) { + setSimulatorSettings(settings); + }, + getMap() { + return mapRef.current; + }, + }), []); + + // ----------------------------------------------------------------------- + // onMapReady — wires map ref + initial fit + // ----------------------------------------------------------------------- + const handleMapReady = useCallback((m: maplibregl.Map) => { + mapRef.current = m; + fitToPlaces(m, competitorsRef.current); + }, [fitToPlaces]); + + return { + // Data + competitors, + loading, + initialCenter, + finalGadmRegions, + initialSimulatorSettings, + // Imperative + handle, + handleMapReady, + mapRef, + hasFittedRef, + }; +} diff --git a/packages/ui/src/lib/registerWidgets.ts b/packages/ui/src/lib/registerWidgets.ts index 24af90aa..94a6bab3 100644 --- a/packages/ui/src/lib/registerWidgets.ts +++ b/packages/ui/src/lib/registerWidgets.ts @@ -10,6 +10,7 @@ import { MessageSquare, Map as MapIcon, } from 'lucide-react'; + import type { HtmlWidgetProps, PhotoGridProps, @@ -24,7 +25,6 @@ import type { } from '@polymech/shared'; import PageCardWidget from '@/modules/pages/PageCardWidget'; - import PhotoGrid from '@/components/PhotoGrid'; import PhotoGridWidget from '@/components/widgets/PhotoGridWidget'; import PhotoCardWidget from '@/components/widgets/PhotoCardWidget'; @@ -36,10 +36,10 @@ import { HtmlWidget } from '@/components/widgets/HtmlWidget'; import HomeWidget from '@/components/widgets/HomeWidget'; import VideoBannerWidget from '@/components/widgets/VideoBannerWidget'; import CategoryFeedWidget from '@/components/widgets/CategoryFeedWidget'; -import SupportChatWidget from '@/components/widgets/SupportChatWidget'; import MenuWidget from '@/components/widgets/MenuWidget'; -import CompetitorsMapWidget from '@/components/widgets/CompetitorsMapWidget'; +const SupportChatWidget = lazy(() => import('@/components/widgets/SupportChatWidget')); +const CompetitorsMapWidget = lazy(() => import('@/components/widgets/CompetitorsMapWidget')); const FileBrowserWidget = lazy(() => import('@/modules/storage/FileBrowserWidget').then(m => ({ default: m.FileBrowserWidget }))); export function registerAllWidgets() { @@ -1306,6 +1306,7 @@ export function registerAllWidgets() { showLocations: true, posterMode: false, enableLocationDetails: true, + maxHeight: '800px', preset: 'Minimal', variables: {} }, @@ -1378,6 +1379,12 @@ export function registerAllWidgets() { label: 'Allow Location Details', description: 'Click on a location to open the detail panel with address, ratings, and contact info.', default: true + }, + maxHeight: { + type: 'string', + label: 'Max Height', + description: 'Maximum height of the map widget (e.g. 800px, 100vh, none).', + default: '800px' } }, minSize: { width: 400, height: 400 }, diff --git a/packages/ui/src/modules/places/LocationDetail.tsx b/packages/ui/src/modules/places/LocationDetail.tsx index 1a6e36fd..af1f9c82 100644 --- a/packages/ui/src/modules/places/LocationDetail.tsx +++ b/packages/ui/src/modules/places/LocationDetail.tsx @@ -4,13 +4,13 @@ import { ArrowLeft, MapPin, Globe, Phone, Clock, Calendar, Image as ImageIcon, I import Lightbox from "yet-another-react-lightbox"; import "yet-another-react-lightbox/styles.css"; import { API_URL, THUMBNAIL_WIDTH } from '../../constants'; -import type { CompetitorFull } from '@polymech/shared'; +import type { PlaceFull } from '@polymech/shared'; import { fetchCompetitorById, fetchPlacePhotos } from './client-gridsearch'; import MarkdownRenderer from '../../components/MarkdownRenderer'; import { T, translate } from '../../i18n'; // Extracted Presentation Component -export const LocationDetailView = React.memo(({ competitor, onClose, livePhotos }: { competitor: CompetitorFull; onClose?: () => void; livePhotos?: any }) => { +export const LocationDetailView = React.memo(({ competitor, onClose, livePhotos }: { competitor: PlaceFull; onClose?: () => void; livePhotos?: any }) => { const [lightboxOpen, setLightboxOpen] = useState(false); const [lightboxIndex, setLightboxIndex] = useState(0); const [activeTab, setActiveTab] = useState<'overview' | 'homepage' | 'debug'>('overview'); @@ -22,7 +22,7 @@ export const LocationDetailView = React.memo(({ competitor, onClose, livePhotos // Reset local fetched state when competitor changes setFetchedPhotos(null); setIsFetchingPhotos(false); - + // Fetch photos on-the-fly (async, non-blocking) if we don't already have them if (!livePhotos && !competitor.raw_data?.google_media?.photos?.length) { setIsFetchingPhotos(true); @@ -449,7 +449,7 @@ export const LocationDetailView = React.memo(({ competitor, onClose, livePhotos const LocationDetail: React.FC = () => { const { place_id } = useParams<{ place_id: string }>(); - const [competitor, setCompetitor] = useState(null); + const [competitor, setCompetitor] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [livePhotos, setLivePhotos] = useState(null); diff --git a/packages/ui/src/modules/places/CompetitorsGridView.tsx b/packages/ui/src/modules/places/PlacesGridView.tsx similarity index 94% rename from packages/ui/src/modules/places/CompetitorsGridView.tsx rename to packages/ui/src/modules/places/PlacesGridView.tsx index fbdb9d90..5b044fa9 100644 --- a/packages/ui/src/modules/places/CompetitorsGridView.tsx +++ b/packages/ui/src/modules/places/PlacesGridView.tsx @@ -1,24 +1,24 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'; import { useSearchParams } from 'react-router-dom'; import { type GridPreset, getPresetVisibilityModel } from './useGridColumns'; -import { - DataGrid, - useGridApiRef, +import { + DataGrid, + useGridApiRef, GridToolbarContainer, GridToolbarColumnsButton, GridToolbarFilterButton, GridToolbarExport, GridToolbarQuickFilter, - type GridColDef, - type GridFilterModel, - type GridPaginationModel, - type GridSortModel, - type GridColumnVisibilityModel + type GridColDef, + type GridFilterModel, + type GridPaginationModel, + type GridSortModel, + type GridColumnVisibilityModel } from '@mui/x-data-grid'; -import { type CompetitorFull } from '@polymech/shared'; +import { type PlaceFull } from '@polymech/shared'; import { ThemeProvider as MuiThemeProvider } from '@mui/material/styles'; import { useMuiTheme } from '@/hooks/useMuiTheme'; -import { type CompetitorSettings } from './useCompetitorSettings'; +import { type PlacesSettings } from './usePlacesSettings'; // Extracted utils and components import { @@ -31,12 +31,11 @@ import { paramsToColumnOrder } from './gridUtils'; import { useGridColumns } from './useGridColumns'; -import { GripVertical } from 'lucide-react'; -interface CompetitorsGridViewProps { - competitors: CompetitorFull[]; +interface PlacesGridViewProps { + competitors: PlaceFull[]; loading: boolean; - settings: CompetitorSettings; + settings: PlacesSettings; updateExcludedTypes: (types: string[]) => Promise; selectedPlaceId?: string | null; onSelectPlace?: (id: string | null, behavior?: 'select' | 'open' | 'toggle') => void; @@ -54,7 +53,7 @@ const CustomToolbar = ({ selectedCount }: { selectedCount: number }) => {
- +
{selectedCount > 0 && ( @@ -66,12 +65,12 @@ const CustomToolbar = ({ selectedCount }: { selectedCount: number }) => { ); }; -export const CompetitorsGridView: React.FC = ({ - competitors, - loading, - settings, - updateExcludedTypes, - selectedPlaceId, +export const CompetitorsGridView: React.FC = ({ + competitors, + loading, + settings, + updateExcludedTypes, + selectedPlaceId, onSelectPlace, isOwner = false, isPublic = false, @@ -86,7 +85,7 @@ export const CompetitorsGridView: React.FC = ({ if (fromUrl.items.length === 0 && !searchParams.has('nofilter')) { // Only apply default "valid leads" filter if we are NOT in a public stripped view const shouldHideEmptyEmails = !isPublic || isOwner; - + if (shouldHideEmptyEmails) { return { items: [{ field: 'email', operator: 'isNotEmpty' }] @@ -138,7 +137,7 @@ export const CompetitorsGridView: React.FC = ({ // Selection state const [selectedRows, setSelectedRows] = useState([]); - + const apiRef = useGridApiRef(); const containerRef = useRef(null); const [highlightedRowId, setHighlightedRowId] = useState(null); @@ -158,7 +157,7 @@ export const CompetitorsGridView: React.FC = ({ settings, updateExcludedTypes }); - + // Sync Visibility Model when preset changes useEffect(() => { const presetModel = getPresetVisibilityModel(preset); @@ -328,10 +327,10 @@ export const CompetitorsGridView: React.FC = ({ const allSortedIds = apiRef.current.getSortedRowIds?.() || []; // Retrieve MUI's internal lookup of which rows are hidden by filters const filteredRowsLookup = apiRef.current.state?.filter?.filteredRowsLookup || {}; - + // Keep only rows that are NOT explicitly filtered out const rowIds = allSortedIds.filter(id => filteredRowsLookup[id] !== false); - + if (rowIds.length === 0) return; const maxIdx = rowIds.length - 1; @@ -381,14 +380,14 @@ export const CompetitorsGridView: React.FC = ({ const nextId = String(rowIds[nextIdx]); setHighlightedRowId(nextId); onSelectPlace?.(nextId, 'select'); - + if (e.shiftKey) { const anchorIdx = anchorRowId ? rowIds.indexOf(anchorRowId) : currentIdx; const effectiveAnchor = anchorIdx >= 0 ? anchorIdx : currentIdx; const start = Math.min(effectiveAnchor, nextIdx); const end = Math.max(effectiveAnchor, nextIdx); const newRange = rowIds.slice(start, end + 1).map(id => String(id)); - + if (e.ctrlKey || e.metaKey) { setSelectedRows(prev => Array.from(new Set([...prev, ...newRange]))); } else { @@ -418,7 +417,7 @@ export const CompetitorsGridView: React.FC = ({ }, [selectedPlaceId, highlightedRowId, competitors]); return ( -
= { } }; -interface CompetitorsMapViewProps { +interface PlacesMapViewProps { preset?: MapPreset; customFeatures?: Partial; - competitors: CompetitorFull[]; + places: PlaceFull[]; onMapCenterUpdate: (loc: string, zoom?: number) => void; initialCenter?: { lat: number, lng: number }; initialZoom?: number; @@ -120,9 +120,11 @@ interface CompetitorsMapViewProps { setPosterTheme?: (theme: string) => void; selectedPlaceId?: string | null; onSelectPlace?: (id: string | null, behavior?: 'select' | 'open' | 'toggle') => void; + /** Optional callback fired once after the internal map instance is created. */ + onMapReady?: (map: maplibregl.Map) => void; } -export const CompetitorsMapView: React.FC = ({ competitors, onMapCenterUpdate, initialCenter, initialZoom, initialPitch, initialBearing, onMapMove, enrich, isEnriching, enrichmentProgress, initialGadmRegions, initialSimulatorSettings, simulatorSettings, onSimulatorSettingsChange, liveAreas = [], liveRadii = [], liveNodes = [], liveScanner, preset = 'SearchView', customFeatures, onRegionsChange, isPosterMode, onClosePosterMode, posterTheme: controlledPosterTheme, setPosterTheme: setControlledPosterTheme, selectedPlaceId, onSelectPlace }) => { +export const PlacesMapView: React.FC = ({ places, onMapCenterUpdate, initialCenter, initialZoom, initialPitch, initialBearing, onMapMove, enrich, isEnriching, enrichmentProgress, initialGadmRegions, initialSimulatorSettings, simulatorSettings, onSimulatorSettingsChange, liveAreas = [], liveRadii = [], liveNodes = [], liveScanner, preset = 'SearchView', customFeatures, onRegionsChange, isPosterMode, onClosePosterMode, posterTheme: controlledPosterTheme, setPosterTheme: setControlledPosterTheme, selectedPlaceId, onSelectPlace, onMapReady }) => { const features: MapFeatures = useMemo(() => { if (isPosterMode) { return { ...MAP_PRESETS['Minimal'], enableSidebarTools: false }; @@ -154,16 +156,16 @@ export const CompetitorsMapView: React.FC = ({ competit }, [theme]); // Selection and Sidebar State - const [selectedLocation, setSelectedLocation] = useState(null); + const [selectedLocation, setSelectedLocation] = useState(null); - const handleSelectLocation = useCallback((loc: CompetitorFull | null, behavior: 'select' | 'open' | 'toggle' = 'open') => { + const handleSelectLocation = useCallback((loc: PlaceFull | null, behavior: 'select' | 'open' | 'toggle' = 'open') => { setSelectedLocation(loc); onSelectPlace?.(loc?.place_id || null, behavior); }, [onSelectPlace]); // Add logic to label locations A, B, C... const validLocations = useMemo(() => { - return competitors + return places .map((c, i) => { const lat = c.gps_coordinates?.latitude ?? c.raw_data?.geo?.latitude ?? (c as any).lat; const lon = c.gps_coordinates?.longitude ?? c.raw_data?.geo?.longitude ?? (c as any).lon; @@ -179,7 +181,7 @@ export const CompetitorsMapView: React.FC = ({ competit return null; }) .filter((c): c is NonNullable => c !== null); - }, [competitors]); + }, [places]); const locationIds = useMemo(() => { return validLocations.map(l => l.place_id).sort().join(','); @@ -198,6 +200,7 @@ export const CompetitorsMapView: React.FC = ({ competit }, [selectedPlaceId, validLocations]); const [gadmPickerActive, setGadmPickerActive] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(features.showSidebar ?? false); + const lastInitialGidsRef = useRef(null); // Grid Search Simulator State const [simulatorActive, setSimulatorActive] = useState(false); @@ -241,6 +244,11 @@ export const CompetitorsMapView: React.FC = ({ competit useEffect(() => { if (!features.enableAutoRegions || !initialGadmRegions || initialGadmRegions.length === 0) return; + // Prevent redundant loads if gids haven't changed (prevents flickering Expand button) + const gidsKey = initialGadmRegions.map(r => r.gid).sort().join(','); + if (lastInitialGidsRef.current === gidsKey) return; + lastInitialGidsRef.current = gidsKey; + (async () => { const regions: any[] = []; const polygons: any[] = []; @@ -260,7 +268,7 @@ export const CompetitorsMapView: React.FC = ({ competit } setPickerRegions(regions); setPickerPolygons(polygons); - + // Allow bounds to be fitted again for new regions hasFittedBoundsRef.current = false; })(); @@ -353,6 +361,8 @@ export const CompetitorsMapView: React.FC = ({ competit useEffect(() => { if (map.current || !mapContainer.current) return; + console.log('initialCenter', initialCenter); + map.current = new maplibregl.Map({ container: mapContainer.current, style: MAP_STYLES[mapStyleKey], @@ -387,6 +397,7 @@ export const CompetitorsMapView: React.FC = ({ competit }); const cleanupListeners = setupMapListeners(map.current, onMapMoveRef.current); + onMapReady?.(map.current); return () => { cleanupListeners(); @@ -397,18 +408,6 @@ export const CompetitorsMapView: React.FC = ({ competit } }; }, []); - - // Needed for widget mode: React to initialCenter updates (e.g. from picker) - useEffect(() => { - if (map.current && initialCenter) { - map.current.flyTo({ - center: [initialCenter.lng, initialCenter.lat], - zoom: initialZoom ?? (map.current.getZoom() < 8 ? 13 : map.current.getZoom()), - duration: 2000 - }); - } - }, [initialCenter, initialZoom]); - const lastFittedLocationIdsRef = useRef(null); // Handle Data Updates - No longer creates manual markers. LocationLayers handles this via WebGL. @@ -419,16 +418,17 @@ export const CompetitorsMapView: React.FC = ({ competit if (validLocations.length > 0 && lastFittedLocationIdsRef.current !== locationIds) { const isInitialLoad = lastFittedLocationIdsRef.current === null; - // Only fit bounds on the first-time data detection. - // We skip camera movement for subsequent live updates (discovered locations). - if (!isPosterMode && !initialCenter && isInitialLoad) { + // Fit bounds on first-time data load only (skip poster mode and subsequent live updates). + // initialCenter only affects the map's starting position (in the init effect), + // it should not prevent fitting to actual data once it arrives. + if (!isPosterMode && isInitialLoad) { const bounds = new maplibregl.LngLatBounds(); validLocations.forEach(loc => bounds.extend([loc.lon, loc.lat])); map.current.fitBounds(bounds, { padding: 50, maxZoom: 15 }); } lastFittedLocationIdsRef.current = locationIds; } - }, [locationIds, validLocations, initialCenter, isPosterMode]); + }, [locationIds, validLocations, isPosterMode]); // Sync Theme/Style @@ -588,6 +588,11 @@ export const CompetitorsMapView: React.FC = ({ competit {/* Split View Container */} @@ -630,6 +635,7 @@ export const CompetitorsMapView: React.FC = ({ competit onSelectionChange={(r, p) => { setPickerRegions(r); setPickerPolygons(p); + onRegionsChange?.(r); }} initialRegions={initialGadmRegions} /> @@ -687,7 +693,7 @@ export const CompetitorsMapView: React.FC = ({ competit )} {/* Map Viewport */} -
+
= ({ competit {/* Needed for widget mode: mt-auto wrapper to force footer to the bottom of the flex stack */}
handleLocate(map.current)} - onZoomToFit={() => { - if (!map.current) return; - const bounds = new maplibregl.LngLatBounds(); - let hasPoints = false; + map={map.current} + currentCenterLabel={currentCenterLabel} + mapInternals={mapInternals} + isLocating={isLocating} + onLocate={() => handleLocate(map.current)} + onZoomToFit={() => { + if (!map.current) return; + const bounds = new maplibregl.LngLatBounds(); + let hasPoints = false; - if (validLocations.length > 0) { - validLocations.forEach(loc => bounds.extend([loc.lon, loc.lat])); - hasPoints = true; - } + if (validLocations.length > 0) { + validLocations.forEach(loc => bounds.extend([loc.lon, loc.lat])); + hasPoints = true; + } - if (pickerPolygons.length > 0) { - pickerPolygons.forEach(fc => { - fc?.features?.forEach((f: any) => { - const coords = f.geometry?.coordinates; - if (!coords) return; - if (f.geometry.type === 'MultiPolygon') { - coords.forEach((poly: any) => poly[0]?.forEach((c: any) => { bounds.extend(c); hasPoints = true; })); - } else if (f.geometry.type === 'Polygon') { - coords[0]?.forEach((c: any) => { bounds.extend(c); hasPoints = true; }); - } + if (pickerPolygons.length > 0) { + pickerPolygons.forEach(fc => { + fc?.features?.forEach((f: any) => { + const coords = f.geometry?.coordinates; + if (!coords) return; + if (f.geometry.type === 'MultiPolygon') { + coords.forEach((poly: any) => poly[0]?.forEach((c: any) => { bounds.extend(c); hasPoints = true; })); + } else if (f.geometry.type === 'Polygon') { + coords[0]?.forEach((c: any) => { bounds.extend(c); hasPoints = true; }); + } + }); }); - }); - } + } - if (hasPoints && !bounds.isEmpty()) { - map.current.fitBounds(bounds, { padding: 100, maxZoom: 15 }); - } - }} - activeStyleKey={mapStyleKey} - onStyleChange={setMapStyleKey} - > - {features.enableLayerToggles && ( - - )} - {features.enableRuler && } - {features.enableEnrichment && ( - - )} + if (hasPoints && !bounds.isEmpty()) { + map.current.fitBounds(bounds, { padding: 100, maxZoom: 15 }); + } + }} + activeStyleKey={mapStyleKey} + onStyleChange={setMapStyleKey} + > + {features.enableLayerToggles && ( + + )} + {features.enableRuler && } + {features.enableEnrichment && ( + + )} - {features.enableEnrichment && enrichmentProgress && ( -
- - {enrichmentProgress.message} ({enrichmentProgress.current}/{enrichmentProgress.total}) -
- )} + {features.enableEnrichment && enrichmentProgress && ( +
+ + {enrichmentProgress.message} ({enrichmentProgress.current}/{enrichmentProgress.total}) +
+ )}
diff --git a/packages/ui/src/modules/places/CompetitorsMetaView.tsx b/packages/ui/src/modules/places/PlacesMetaView.tsx similarity index 92% rename from packages/ui/src/modules/places/CompetitorsMetaView.tsx rename to packages/ui/src/modules/places/PlacesMetaView.tsx index a1f8685d..c0eca891 100644 --- a/packages/ui/src/modules/places/CompetitorsMetaView.tsx +++ b/packages/ui/src/modules/places/PlacesMetaView.tsx @@ -1,21 +1,21 @@ import React, { useMemo } from 'react'; -import { type CompetitorFull } from '@polymech/shared'; +import { type PlaceFull } from '@polymech/shared'; import { PieChart, AlertCircle, CheckCircle2, XCircle } from 'lucide-react'; -import type { CompetitorSettings } from './useCompetitorSettings'; +import type { CompetitorSettings } from './usePlacesSettings'; -interface CompetitorsMetaViewProps { - competitors: CompetitorFull[]; +interface PlacesMetaViewProps { + places: PlaceFull[]; settings: CompetitorSettings; updateExcludedTypes: (types: string[]) => void; } -export const CompetitorsMetaView: React.FC = ({ competitors, settings, updateExcludedTypes }) => { +export const PlacesMetaView: React.FC = ({ places, settings, updateExcludedTypes }) => { // Aggregate types const typeStats = useMemo(() => { const stats: Record = {}; - competitors.forEach(comp => { + places.forEach(comp => { if (comp.types) { comp.types.forEach(t => { stats[t] = (stats[t] || 0) + 1; @@ -33,7 +33,7 @@ export const CompetitorsMetaView: React.FC = ({ compet // If we want to UN-exclude, we need to list the excluded types somewhere. return Object.entries(stats).sort((a, b) => b[1] - a[1]); - }, [competitors]); + }, [places]); const handleToggleExclusion = (type: string) => { const currentExcluded = settings.excluded_types || []; diff --git a/packages/ui/src/modules/places/CompetitorSettingsDialog.tsx b/packages/ui/src/modules/places/PlacesSettingsDialog.tsx similarity index 93% rename from packages/ui/src/modules/places/CompetitorSettingsDialog.tsx rename to packages/ui/src/modules/places/PlacesSettingsDialog.tsx index 8ec8a795..16121321 100644 --- a/packages/ui/src/modules/places/CompetitorSettingsDialog.tsx +++ b/packages/ui/src/modules/places/PlacesSettingsDialog.tsx @@ -12,16 +12,16 @@ import { Input } from '@/components/ui/input'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Checkbox } from '@/components/ui/checkbox'; import { Search, Loader2 } from 'lucide-react'; -import { useCompetitorSettings, type CompetitorSettings } from './useCompetitorSettings'; +import { type PlacesSettings } from './usePlacesSettings'; -interface CompetitorSettingsDialogProps { +interface PlacesSettingsDialogProps { open: boolean; onOpenChange: (open: boolean) => void; - settings: CompetitorSettings; + settings: PlacesSettings; updateExcludedTypes: (types: string[]) => Promise; } -export const CompetitorSettingsDialog: React.FC = ({ +export const PlacesSettingsDialog: React.FC = ({ open, onOpenChange, settings, diff --git a/packages/ui/src/modules/places/CompetitorsThumbView.tsx b/packages/ui/src/modules/places/PlacesThumbView.tsx similarity index 82% rename from packages/ui/src/modules/places/CompetitorsThumbView.tsx rename to packages/ui/src/modules/places/PlacesThumbView.tsx index 192b3951..037f9cbf 100644 --- a/packages/ui/src/modules/places/CompetitorsThumbView.tsx +++ b/packages/ui/src/modules/places/PlacesThumbView.tsx @@ -1,50 +1,50 @@ import React from 'react'; import { Link } from 'react-router-dom'; import { MapPin, Globe, Phone, Clock } from 'lucide-react'; -import { type CompetitorFull } from '@polymech/shared'; -import { THUMBNAIL_WIDTH } from '../../constants'; +import { type PlaceFull } from '@polymech/shared'; +import { THUMBNAIL_WIDTH } from '@/constants'; -interface CompetitorsThumbViewProps { - competitors: CompetitorFull[]; +interface PlacesThumbViewProps { + places: PlaceFull[]; filters: string[]; toggleFilter: (filter: string) => void; } -export const CompetitorsThumbView: React.FC = ({ - competitors, +export const PlacesThumbView: React.FC = ({ + places, filters, toggleFilter }) => { return (
- {competitors.map((competitor) => { - let imageUrl = competitor.raw_data?.google_media?.photos?.[0]?.image; + {places.map((place) => { + let imageUrl = place.raw_data?.google_media?.photos?.[0]?.image; if (imageUrl) { imageUrl = imageUrl.replace(/=w\d+[^&]*/, `=w${THUMBNAIL_WIDTH}`); } - imageUrl = imageUrl || competitor.thumbnail || undefined; + imageUrl = imageUrl || place.thumbnail || undefined; return ( -
+
{imageUrl && (
{competitor.title}
)}
- -

{competitor.title}

+ +

{place.title}

{(() => { - const businessTypes = competitor.types || []; - const mediaCategories = competitor.raw_data?.google_media?.categories || []; - const city = competitor.city || competitor.raw_data?.geo?.city; + const businessTypes = place.types || []; + const mediaCategories = place.raw_data?.google_media?.categories || []; + const city = place.city || place.raw_data?.geo?.city; // Display up to 3 items total, prioritizing city, then business types const displayLimit = 3; @@ -127,23 +127,23 @@ export const CompetitorsThumbView: React.FC = ({
- {competitor.address} + {place.address}
- {competitor.phone && ( + {place.phone && (
- {competitor.phone} + {place.phone}
)} - {competitor.website && ( + {place.website && ( )} - {competitor.operating_hours && ( + {place.operating_hours && (
Operating hours available diff --git a/packages/ui/src/modules/places/components/MapPosterOverlay.tsx b/packages/ui/src/modules/places/components/MapPosterOverlay.tsx index 10e2452b..3318638a 100644 --- a/packages/ui/src/modules/places/components/MapPosterOverlay.tsx +++ b/packages/ui/src/modules/places/components/MapPosterOverlay.tsx @@ -178,18 +178,18 @@ export function MapPosterOverlay({ map, pickerRegions, pickerPolygons, posterThe
{/* Bottom Content Area */} -
+
-
+
{displayCity}
-
+
-
+
{country.toUpperCase()}
@@ -197,14 +197,6 @@ export function MapPosterOverlay({ map, pickerRegions, pickerPolygons, posterThe {center ? formatCoords(center) : 'Loading...'}
- - {/* Attribution */} -
- © OpenStreetMap contributors -
); diff --git a/packages/ui/src/modules/places/gadm-picker/GadmPicker.tsx b/packages/ui/src/modules/places/gadm-picker/GadmPicker.tsx index 5545f024..a45431fa 100644 --- a/packages/ui/src/modules/places/gadm-picker/GadmPicker.tsx +++ b/packages/ui/src/modules/places/gadm-picker/GadmPicker.tsx @@ -19,13 +19,6 @@ export interface GadmRegion { -function createMarkerEl(): HTMLElement { - const el = document.createElement('div'); - el.innerHTML = ``; - el.style.cursor = 'move'; - return el; -} - const MAX_DISPLAY_LEVEL: Record = { 0: 1, 1: 3, @@ -61,8 +54,8 @@ const LEVEL_OPTIONS = [ ]; export function GadmPicker({ map, active, onClose, onSelectionChange, className = "", initialRegions }: GadmPickerProps) { - const [levelOption, setLevelOption] = useState(0); - const [resolutionOption, setResolutionOption] = useState(1); + const [levelOption, setLevelOption] = useState(3); + const [resolutionOption, setResolutionOption] = useState(3); const enrich = true; // UI state @@ -82,7 +75,6 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className const [geojsons, setGeojsons] = useState>({}); // Extracted Inspector State - const markerRef = useRef(null); const [inspectedHierarchy, setInspectedHierarchy] = useState(null); const [inspectedPoint, setInspectedPoint] = useState<{ lat: number, lng: number } | null>(null); const [inspectedGeojson, setInspectedGeojson] = useState(null); @@ -188,24 +180,54 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className }; }, [map, updateMapFeatures]); - // Handle Inspector Marker + // Map Interactions + useEffect(() => { + if (!map || !active) return; + + const handleMapClick = (e: maplibregl.MapMouseEvent) => { + // Left click handles normal inspection (or multi-select if Ctrl is held) + if (e.originalEvent.button === 0) { + performInspection(e.lngLat.lat, e.lngLat.lng, e.originalEvent.ctrlKey || e.originalEvent.metaKey); + } + }; + + const handleMouseDown = (e: maplibregl.MapMouseEvent) => { + // Middle click (button 1) acts as a shortcut for "Add to selection" + if (e.originalEvent.button === 1) { + performInspection(e.lngLat.lat, e.lngLat.lng, true); + + // Prevent MapLibre from starting a drag-rotate with middle mouse + e.originalEvent.preventDefault(); + e.originalEvent.stopPropagation(); + } + }; + + map.on('click', handleMapClick); + map.on('mousedown', handleMouseDown); + + const canvas = map.getCanvas(); + const oldCursor = canvas.style.cursor; + canvas.style.cursor = 'crosshair'; + + const handleMouseEnter = () => { canvas.style.cursor = 'crosshair'; }; + map.on('mouseenter', handleMouseEnter); + + return () => { + map.off('click', handleMapClick); + map.off('mousedown', handleMouseDown); + map.off('mouseenter', handleMouseEnter); + canvas.style.cursor = oldCursor; + }; + }, [map, active]); + + // Handle Inspector Clear on deactivate useEffect(() => { if (!map || !active) { - markerRef.current?.remove(); - markerRef.current = null; setInspectedGeojson(null); + setInspectedHierarchy(null); + setInspectedPoint(null); return; } - - if (!markerRef.current) { - markerRef.current = new maplibregl.Marker({ element: createMarkerEl(), draggable: true, anchor: 'bottom' }); - - markerRef.current.on('dragend', async () => { - if (!markerRef.current) return; - const { lat, lng } = markerRef.current.getLngLat(); - await performInspection(lat, lng); - }); - } }, [map, active]); const performInspection = async (lat: number, lng: number, ctrlKey: boolean = false) => { @@ -219,13 +241,6 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className } try { - if (!markerRef.current) return; - markerRef.current.setLngLat([lng, lat]); - - try { - markerRef.current.addTo(map!); - } catch (e) { } - if (id === inspectionIdRef.current) { setInspectedPoint({ lat, lng }); setLoadingInspector(true); @@ -589,10 +604,6 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className setGeojsons({}); setInspectedGeojson(null); setInspectedHierarchy(null); - if (markerRef.current) { - markerRef.current.remove(); - markerRef.current = null; - } }; // Import initialRegions prop (same logic as handleImportJson) @@ -611,7 +622,7 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className // Check if current selectedRegions already contains exactly these gids const currentGids = selectedRegions.map(r => r.gid).sort().join(','); - if (currentGids === key && importedInitialRef.current !== null) { + if (currentGids === key) { importedInitialRef.current = key; return; } diff --git a/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx b/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx index 49ea4371..dbdf778d 100644 --- a/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx +++ b/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx @@ -2,16 +2,16 @@ import React, { useState, useCallback, useRef } from 'react'; import { useSearchParams } from 'react-router-dom'; import { LayoutGrid, List, Map as MapIcon, PieChart, FileText, Terminal, PlusCircle, Loader2, Share2, Image as ImageIcon, Palette, PanelLeftClose, PanelLeftOpen } from 'lucide-react'; -import { CompetitorsGridView } from '../CompetitorsGridView'; -import { CompetitorsMapView } from '../CompetitorsMapView'; -import { CompetitorsThumbView } from '../CompetitorsThumbView'; -import { CompetitorsMetaView } from '../CompetitorsMetaView'; -import { CompetitorsReportView } from './CompetitorsReportView'; +import { CompetitorsGridView } from '../PlacesGridView'; +import { PlacesMapView } from '../PlacesMapView'; +import { PlacesThumbView } from '../PlacesThumbView'; +import { PlacesMetaView } from '../PlacesMetaView'; +import { PlacesReportView } from './PlacesReportView'; import { useRestoredSearch } from './RestoredSearchContext'; import { expandPlacesGridSearch } from '../client-gridsearch'; import { POSTER_THEMES } from '../utils/poster-themes'; -import { type CompetitorFull } from '@polymech/shared'; +import { type PlaceFull } from '@polymech/shared'; import { type LogEntry } from '@/contexts/LogContext'; import ChatLogBrowser from '@/components/ChatLogBrowser'; import { GripVertical } from 'lucide-react'; @@ -21,7 +21,7 @@ type ViewMode = 'grid' | 'thumb' | 'map' | 'meta' | 'report' | 'log' | 'poster'; interface GridSearchResultsProps { jobId: string; - competitors: CompetitorFull[]; + competitors: PlaceFull[]; excludedTypes: string[]; updateExcludedTypes?: (types: string[]) => Promise; liveAreas?: any[]; @@ -139,7 +139,7 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes const handleSelectPlace = useCallback((id: string | null, behavior: 'select' | 'open' | 'toggle' = 'select') => { const isSame = id === selectedPlaceId; setSelectedPlaceId(id); - + if (behavior === 'open') { setShowDetails(true); } else if (behavior === 'toggle') { @@ -182,10 +182,10 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes if ((window as any).__MAP_PERF_DEBUG__) console.log('[MapPerf] onMapMove debounce reset (prevented URL update)'); clearTimeout(mapMoveTimerRef.current); } - + mapMoveTimerRef.current = setTimeout(() => { if ((window as any).__MAP_PERF_DEBUG__) console.log('[MapPerf] onMapMove → window.history.replaceState committed (300ms debounce)'); - + // USE MANUAL HISTORY API: Updates the URL bar silently WITHOUT triggering React re-renders/useSearchParams const url = new URL(window.location.href); url.searchParams.set('mapLat', state.lat.toFixed(6)); @@ -193,7 +193,7 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes url.searchParams.set('mapZoom', state.zoom.toFixed(2)); if (state.pitch !== undefined) url.searchParams.set('mapPitch', state.pitch.toFixed(0)); if (state.bearing !== undefined) url.searchParams.set('mapBearing', state.bearing.toFixed(0)); - + // Maintain view state if it's not locked to 'poster' if (url.searchParams.get('view') !== 'poster') { url.searchParams.set('view', 'map'); @@ -360,17 +360,17 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes )} {viewMode === 'thumb' && ( - { }} /> )} {(viewMode === 'map' || viewMode === 'poster') && ( - { })} /> )} {viewMode === 'report' && ( - + )} {viewMode === 'log' && import.meta.env.DEV && sseLogs && ( @@ -446,8 +446,8 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
{/* Property Pane */} -
{ - if (step === 1 && collectedNodes.length === 0) return; if (step === 2 && !searchQuery.trim()) return; if (step === 1) { setStep(2); return; } @@ -244,9 +243,9 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings, setIsSidebarOp

Preview & Simulate

- { }} enrich={async () => { }} isEnriching={false} @@ -424,7 +423,7 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings, setIsSidebarOp {step < 4 ? (