Compare commits
318 Commits
c09598f93d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b3d56e1e8 | |||
| 265e1c934b | |||
| ddf27d9b17 | |||
| 377b88c48d | |||
| b356e70148 | |||
| 708e550d06 | |||
| 0cce1c8170 | |||
| 34be6c58bc | |||
| 7bc9e67e96 | |||
| 93b3904755 | |||
| f44d57629f | |||
| 8222be2f0e | |||
| 177f2104c1 | |||
| f106a2bb02 | |||
| 27960c9a43 | |||
| fc7a1f9787 | |||
| ef3719187d | |||
| 7a93a28055 | |||
| f822509169 | |||
| d76c49fb9e | |||
| 9e7abfafc8 | |||
| 4d1883b459 | |||
| 5bcf65ff02 | |||
| 89e7d08d19 | |||
| 126a121056 | |||
| 31877fde9e | |||
| 0a38b2c374 | |||
| b8061ae31f | |||
| 145aa11c55 | |||
| 730eee6c8a | |||
| 4410e968db | |||
| 82a225de67 | |||
| adc0b0a0d0 | |||
| 9bf79a15ed | |||
| b46eb7a70f | |||
| ee86af6183 | |||
| 870e1bd4c2 | |||
| c269d8b296 | |||
| aac93678bb | |||
| c00d4fe157 | |||
| f5c8cf5fdc | |||
| b33fe35c4b | |||
| 73456c1424 | |||
| 9eb2b12fea | |||
| dfb5ceb926 | |||
| 91fb23bc53 | |||
| 58442da355 | |||
| 9bab9db4df | |||
| c82e234d6c | |||
| ecf61e7e1d | |||
| dafb3555d7 | |||
| 0d5fcf909c | |||
| 236635fbf4 | |||
| 0572097fb2 | |||
| 938aa2c6d3 | |||
| 8ebd7e4ee2 | |||
| 536b3659bb | |||
| 67f3237761 | |||
| 675b1e51dd | |||
| a83f9e63b9 | |||
| a75d0e1adc | |||
| e42eeb6324 | |||
| 9d45799900 | |||
| 946723197e | |||
| 3ea57f00b6 | |||
| 311f63e812 | |||
| 1d233fdc19 | |||
| c6eb1a9450 | |||
| 49a239006d | |||
| 6c5aa61f09 | |||
| 4c1ffe3d01 | |||
| 4e67c29267 | |||
| acb9be8345 | |||
| 189e9a218a | |||
| c4516c6f23 | |||
| 798b3e4f6b | |||
| a6d7e1d87f | |||
| 54d9a36686 | |||
| 24b565f5ea | |||
| bde25d8d84 | |||
| 8b6d6ba1d0 | |||
| e5da0301de | |||
| 318cb6037e | |||
| 3b456eb481 | |||
| 8f65efd5d1 | |||
| eab465b8c3 | |||
| 0c4b91707f | |||
| c012adaa77 | |||
| d82b873552 | |||
| 12ff629fbf | |||
| 8acafe958b | |||
| 45d4d1bf40 | |||
| 6f46a85ff3 | |||
| 2cd35c790d | |||
| 7a36f0b613 | |||
| 1919155d41 | |||
| 1a5487f0f7 | |||
| daca222427 | |||
| f1f4dc097e | |||
| e420cd4609 | |||
| 9df6c9f542 | |||
| aebe1d521c | |||
| 2f114d47de | |||
| c1006f395c | |||
| a191a684b2 | |||
| 3614c2fc4a | |||
| 4beac959c8 | |||
| b0a5918bd7 | |||
| 5966a11f7e | |||
| 0e5c01839d | |||
| d780c3c973 | |||
| 7a28d3ad33 | |||
| 4707c6444e | |||
| e5e2fabb1d | |||
| 227c363e13 | |||
| 64f10a63ff | |||
| aa11ca389e | |||
| 1dac5206e4 | |||
| 3da45a4cab | |||
| b1fc7c84a7 | |||
| b87c908415 | |||
| ab35fc4df7 | |||
| 3f5eed25f4 | |||
| 0dc5e58b55 | |||
| ba71ca3ef5 | |||
| 2848868263 | |||
| 2b8d144924 | |||
| d48a2bbf5d | |||
| 3fcf7e2a67 | |||
| 1cc73a3033 | |||
| 024ee0f21a | |||
| 19bed6724b | |||
| 48fe47d2c0 | |||
| 5ff7d4cdd7 | |||
| 91034c41ee | |||
| d9c247fee2 | |||
| 7ae23aebf4 | |||
| d2b69d5ec6 | |||
| dfa4815d75 | |||
| 903dc67ac4 | |||
| 60919122d9 | |||
| 32d3f30f9d | |||
| 8ead985c7e | |||
| 566d7c4bb1 | |||
| 3ffb617970 | |||
| 8362e3fd84 | |||
| 53c241c20f | |||
| c4122cea01 | |||
| 800c45916e | |||
| 3a2262edd0 | |||
| 836d60b72f | |||
| 7d2fe4ade0 | |||
| 78625d6415 | |||
| b38916229e | |||
| 1b679098ab | |||
| ba3edc3321 | |||
| 0af3e16a2b | |||
| 4f694d4458 | |||
| 76c19449f3 | |||
| 6c55264fa3 | |||
| 06932b5ddc | |||
| 2248ecc5d3 | |||
| fff20e0cb9 | |||
| b13a038eb1 | |||
| 1a9ed1ef76 | |||
| 2278226ff1 | |||
| 437d734df6 | |||
| 3346ec709d | |||
| 1b5876524a | |||
| 4ea7c6dbd6 | |||
| 4a144fc397 | |||
| 00a691debd | |||
| c297a2c5f7 | |||
| 53595fdf94 | |||
| a52f9e7586 | |||
| 88754250a8 | |||
| 14a77dd6f7 | |||
| d0c1b5d48e | |||
| ad4c72f527 | |||
| 2886703d0f | |||
| 62777e9778 | |||
| 5a6ab36aa7 | |||
| 87281bc690 | |||
| 7d30e28fdc | |||
| a826f45b24 | |||
| 0c94af75d3 | |||
| a59d9bc923 | |||
| b7302d274a | |||
| c9ecd284c7 | |||
| fcc6f8cc20 | |||
| af30088ee6 | |||
| 6185defa8b | |||
| e63ec4c6c8 | |||
| 84b862471c | |||
| 8488a53e3b | |||
| 08cd7164cb | |||
| 6c60572a3e | |||
| c452bd9fb7 | |||
| fd86910ae3 | |||
| bcb7aeac64 | |||
| 7fc46f75bd | |||
| e13a9351be | |||
| eb8cd18210 | |||
| 23bddf6752 | |||
| 665a51d794 | |||
| d367b5f736 | |||
| f92fcfd86b | |||
| 0447908007 | |||
| 887e3f423e | |||
| 04c74c78e4 | |||
| e35b50e5c2 | |||
| b9993f0573 | |||
| 259f56396b | |||
| b61cd71044 | |||
| 336c46ff8e | |||
| 3921852eb5 | |||
| f6781ab851 | |||
| 86e43cecae | |||
| 7b10f1e533 | |||
| 61a44525bf | |||
| ce49b9e536 | |||
| f9a2f6f82a | |||
| 899b5c4cf7 | |||
| 379e7e4d3f | |||
| 8fa89a7675 | |||
| 431291c410 | |||
| 79750b2a4a | |||
| 86c39473a5 | |||
| 2a25e4b160 | |||
| c8aee1b58e | |||
| a3ab539197 | |||
| aab38d909c | |||
| de52b5dced | |||
| 8e2534ebe3 | |||
| 9d73697fb0 | |||
| 87ac81c6c9 | |||
| 4b5d3bd498 | |||
| 003a2821fd | |||
| e070aedae5 | |||
| f032cf0e4a | |||
| 5a7de39f6a | |||
| f5deccd8ea | |||
| d75fcb1d1c | |||
| 9e73dc3cb9 | |||
| 194ddf0849 | |||
| 81c61d8411 | |||
| 0cd28de733 | |||
| 8275ed1d95 | |||
| 22eb9a4383 | |||
| 75a7ab91ca | |||
| e583fdecc9 | |||
| 06b3a820de | |||
| b0f27053ae | |||
| 55c807dd1b | |||
| 0f928b08e9 | |||
| c892e8d820 | |||
| 1361534c98 | |||
| d8a10fadc0 | |||
| 0cc14a96e9 | |||
| f6fc63a40c | |||
| c5112dbb3d | |||
| 5b18cce5a3 | |||
| 34024404a5 | |||
| 5cb438ef67 | |||
| 4b61d07ffd | |||
| 39d64b033e | |||
| 0f555c55ee | |||
| eb39024548 | |||
| f7190bb98e | |||
| 46de088423 | |||
| 28bb395b06 | |||
| dbed7105b7 | |||
| 8e56aa7b89 | |||
| 2739c6af6f | |||
| d7bd1a7f5d | |||
| 1c51236c31 | |||
| ed504bd1de | |||
| 0958238b25 | |||
| dff0bbe97c | |||
| 98c6fcb619 | |||
| b079683a46 | |||
| f10a112de6 | |||
| 9d58f1b705 | |||
| 479afb1039 | |||
| d07d8a8381 | |||
| 1cbdf13145 | |||
| 974d06fff8 | |||
| 6941074106 | |||
| 8e9753fd29 | |||
| 7094114c36 | |||
| 959590acfe | |||
| 1c5ad7c988 | |||
| a2b9ff75b5 | |||
| a96dce56a2 | |||
| 8bcb0bcc81 | |||
| 4467e70973 | |||
| f50ad5e020 | |||
| 442a1565fd | |||
| 31565b418a | |||
| 4ac4a48cad | |||
| 85077251f3 | |||
| f5e19ce3d1 | |||
| f01fe47af4 | |||
| b2519a3b9c | |||
| f5ffce2e23 | |||
| eb7c28ca14 | |||
| b62e01b153 | |||
| 8cec9646c3 | |||
| e30b437dce | |||
| 3a3db3f366 | |||
| b3b585e7c8 | |||
| eb96af3e4b | |||
| 6786ac07d1 | |||
| a0dd35a066 | |||
| f94529c380 | |||
| 179dc306bb | |||
| f1ab165139 | |||
| acfec5abe5 |
@@ -10,3 +10,8 @@ docs/
|
||||
legacy/
|
||||
dwg2dxf-api/
|
||||
.DS_Store
|
||||
.claude/
|
||||
.vscode/
|
||||
.idea/
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
|
||||
+2
-2
@@ -49,8 +49,8 @@ AUTHENTIK_CLIENT_ID=your-authentik-client-id
|
||||
AUTHENTIK_CLIENT_SECRET=your-authentik-client-secret
|
||||
AUTHENTIK_ISSUER=https://auth.beletage.ro/application/o/architools/
|
||||
|
||||
# N8N automation (future)
|
||||
# N8N_WEBHOOK_URL=http://10.10.10.166:5678/webhook
|
||||
# PMTiles rebuild webhook (pmtiles-webhook systemd service on satra)
|
||||
N8N_WEBHOOK_URL=http://10.10.10.166:9876
|
||||
|
||||
# External tool URLs (displayed in dashboard)
|
||||
NEXT_PUBLIC_GITEA_URL=http://10.10.10.166:3002
|
||||
|
||||
@@ -1,344 +1,197 @@
|
||||
# ArchiTools — Project Context for AI Assistants
|
||||
|
||||
> This file provides all context needed for Claude Code, Sonnet, or any AI model to work on this project from scratch.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # http://localhost:3000
|
||||
npx next build # verify zero errors before pushing
|
||||
git push origin main # auto-deploys via Portainer webhook
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Overview
|
||||
|
||||
**ArchiTools** is a modular internal web dashboard for an architecture/engineering office group of 3 companies:
|
||||
|
||||
- **Beletage** (architecture)
|
||||
- **Urban Switch** (urbanism)
|
||||
- **Studii de Teren** (geotechnics)
|
||||
|
||||
It runs on an on-premise Ubuntu server at `10.10.10.166`, containerized with Docker, managed via Portainer, served by Nginx Proxy Manager.
|
||||
|
||||
### Stack
|
||||
|
||||
| Layer | Technology |
|
||||
| ------------ | ---------------------------------------------------------------------------- |
|
||||
| Framework | Next.js 16.x, App Router, TypeScript (strict) |
|
||||
| Styling | Tailwind CSS v4, shadcn/ui |
|
||||
| Database | PostgreSQL (10.10.10.166:5432) via Prisma v6 ORM |
|
||||
| Storage | `DatabaseStorageAdapter` (PostgreSQL) — localStorage fallback available |
|
||||
| File Storage | MinIO (10.10.10.166:9002 API / 9003 UI) — client configured, adapter pending |
|
||||
| Auth | NextAuth v4 + Authentik OIDC (auth.beletage.ro) |
|
||||
| Deploy | Docker multi-stage, Portainer CE, Nginx Proxy Manager |
|
||||
| Repo | Gitea at `https://git.beletage.ro/gitadmin/ArchiTools` |
|
||||
| Language | Code in **English**, UI in **Romanian** |
|
||||
|
||||
### Architecture Principles
|
||||
|
||||
- **Module platform, not monolith** — each module isolated with own types/services/hooks/components
|
||||
- **Feature flags** gate module loading (disabled = zero bundle cost)
|
||||
- **Storage abstraction**: `StorageService` interface with adapters (database default via Prisma, localStorage fallback)
|
||||
- **Cross-module tagging system** as shared service
|
||||
- **Auth via Authentik SSO** — NextAuth v4 + OIDC, group→role/company mapping
|
||||
- **All entities** include `visibility` / `createdBy` fields from day one
|
||||
- **Company logos** — theme-aware (light/dark variants), dual-rendered for SSR safety
|
||||
|
||||
---
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # Routing only (thin wrappers)
|
||||
│ ├── (modules)/ # Module route pages
|
||||
│ └── layout.tsx # App shell
|
||||
├── core/ # Platform services
|
||||
│ ├── module-registry/ # Module registration + types
|
||||
│ ├── feature-flags/ # Flag evaluation + env override
|
||||
│ ├── storage/ # StorageService + adapters
|
||||
│ │ └── adapters/ # localStorage adapter (+ future DB/MinIO)
|
||||
│ ├── tagging/ # Cross-module tag service
|
||||
│ ├── i18n/ # Romanian translations
|
||||
│ ├── theme/ # Light/dark theme
|
||||
│ └── auth/ # Auth types + stub (future Authentik)
|
||||
├── modules/ # Module business logic
|
||||
│ ├── <module-name>/
|
||||
│ │ ├── components/ # Module UI components
|
||||
│ │ ├── hooks/ # Module-specific hooks
|
||||
│ │ ├── services/ # Module business logic
|
||||
│ │ ├── types.ts # Module types
|
||||
│ │ ├── config.ts # Module metadata
|
||||
│ │ └── index.ts # Public exports
|
||||
│ └── ...
|
||||
├── shared/ # Shared UI
|
||||
│ ├── components/
|
||||
│ │ ├── ui/ # shadcn/ui primitives
|
||||
│ │ ├── layout/ # Sidebar, Header
|
||||
│ │ └── common/ # Reusable app components
|
||||
│ ├── hooks/ # Shared hooks
|
||||
│ └── lib/ # Utils (cn, etc.)
|
||||
├── config/ # Global config
|
||||
│ ├── modules.ts # Module registry entries
|
||||
│ ├── flags.ts # Default feature flags
|
||||
│ ├── navigation.ts # Sidebar nav structure
|
||||
│ └── companies.ts # Company definitions
|
||||
docs/ # 16 internal technical docs
|
||||
legacy/ # Original HTML tools for reference
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implemented Modules (16 total — 14 original + 2 new)
|
||||
|
||||
| # | Module | Route | Version | Key Features |
|
||||
| --- | ---------------------- | --------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | **Dashboard** | `/` | 0.1.0 | KPI cards (6), activity feed (last 20), module grid, external tools |
|
||||
| 2 | **Email Signature** | `/email-signature` | 0.1.0 | Multi-company branding, address toggle (BTG/US/SDT), live preview, zoom/copy/download |
|
||||
| 3 | **Word XML Generator** | `/word-xml` | 0.1.0 | Category-based XML gen, simple/advanced mode, ZIP export |
|
||||
| 4 | **Registratura** | `/registratura` | 0.4.0 | CRUD registry, dynamic doc types, bidirectional Address Book, threads, backdating, **legal deadline tracking**, recipient registration, document expiry, **NAS network path attachments** (A/O/P/T drives, copy-to-clipboard), **detail sheet side panel**, **configurable column visibility**, **QuickLook attachment preview** (images: zoom/pan, PDFs: native viewer, multi-file navigation) |
|
||||
| 5 | **Tag Manager** | `/tag-manager` | 0.2.0 | CRUD tags, category/scope/color, US/SDT seeds, mandatory categories, **ManicTime bidirectional sync** |
|
||||
| 6 | **IT Inventory** | `/it-inventory` | 0.2.0 | Dynamic equipment types, rented status (purple pulse), **42U rack visualization**, type/status/company filters |
|
||||
| 7 | **Address Book** | `/address-book` | 0.1.1 | CRUD contacts, card grid, vCard export, Registratura reverse lookup, **dynamic types (creatable)**, **alphabetically sorted type dropdown** |
|
||||
| 8 | **Password Vault** | `/password-vault` | 0.3.0 | CRUD credentials, 9 categorii cu iconițe, **WiFi QR code real**, context-aware form, strength meter, company scope, **AES-256-GCM encryption** |
|
||||
| 9 | **Mini Utilities** | `/mini-utilities` | 0.2.0 | Text case, char counter, percentage, **TVA calculator (19%)**, area converter, U→R, num→text, artifact cleaner, MDLPA, **extreme PDF compression (GS+qpdf)**, PDF unlock, **DWG→DXF**, OCR, color palette, **paste on all drop zones**, **thermal drag-drop reorder** |
|
||||
| 10 | **Prompt Generator** | `/prompt-generator` | 0.2.0 | Template-driven prompt builder, **18 templates** (14 text + 4 image), search bar, target type filter |
|
||||
| 11 | **Digital Signatures** | `/digital-signatures` | 0.1.0 | CRUD assets, drag-and-drop file upload, tag chips |
|
||||
| 12 | **Word Templates** | `/word-templates` | 0.1.0 | Template library, 8 categories, version tracking, .docx placeholder auto-detection |
|
||||
| 13 | **AI Chat** | `/ai-chat` | 0.2.0 | Multi-provider (OpenAI/Claude/Ollama/demo), **project linking via Tag Manager**, provider status badge |
|
||||
| 14 | **Hot Desk** | `/hot-desk` | 0.1.1 | 4 desks, week-ahead calendar, room layout (window+door proportioned), reserve/cancel |
|
||||
| 15 | **ParcelSync** | `/parcel-sync` | 0.5.0 | eTerra ANCPI integration, **PostGIS database**, background sync, 23-layer catalog, enrichment pipeline, owner search, **per-UAT analytics dashboard**, **health check + maintenance detection** |
|
||||
| 16 | **Visual Copilot** | `/visual-copilot` | 0.1.0 | AI-powered image analysis — **developed in separate repo** (`https://git.beletage.ro/gitadmin/vim`), placeholder in ArchiTools, will be merged as module later |
|
||||
|
||||
### Registratura — Legal Deadline Tracking (Termene Legale)
|
||||
|
||||
The Registratura module includes a full legal deadline tracking engine for Romanian construction permitting:
|
||||
|
||||
- **16 deadline types** across 5 categories (Avize, Completări, Analiză, Autorizare, Publicitate)
|
||||
- **Working days vs calendar days** with Romanian public holiday support (including Orthodox Easter via Meeus algorithm)
|
||||
- **Backward deadlines** (e.g., AC extension: 45 working days BEFORE expiry)
|
||||
- **Chain deadlines** (resolving one prompts adding the next)
|
||||
- **Tacit approval** (auto-detected when overdue + applicable type)
|
||||
- **Tabbed UI**: "Registru" tab (existing registry) + "Termene legale" tab (deadline dashboard)
|
||||
|
||||
Key files:
|
||||
|
||||
- `services/working-days.ts` — Romanian holidays, `addWorkingDays()`, `isWorkingDay()`
|
||||
- `services/deadline-catalog.ts` — 16 `DeadlineTypeDef` entries
|
||||
- `services/deadline-service.ts` — `createTrackedDeadline()`, `resolveDeadline()`, `aggregateDeadlines()`
|
||||
- `components/attachment-preview.tsx` — QuickLook-style fullscreen preview (images: zoom/pan, PDFs: blob URL iframe, multi-file nav)
|
||||
- `components/deadline-dashboard.tsx` — Stats + filters + table
|
||||
- `components/deadline-add-dialog.tsx` — 3-step wizard (category → type → date preview)
|
||||
|
||||
### ParcelSync — eTerra ANCPI GIS Integration
|
||||
|
||||
The ParcelSync module connects to Romania's national eTerra/ANCPI cadastral system:
|
||||
|
||||
- **eTerra API client** (`eterra-client.ts`): form-post auth, JSESSIONID cookie jar, session caching (9min TTL), auto-relogin, paginated fetching with `maxRecordCount=1000` + fallback page sizes (500, 200)
|
||||
- **23-layer catalog** (`eterra-layers.ts`): TERENURI_ACTIVE, CLADIRI_ACTIVE, LIMITE_UAT, etc. organized in 6 categories
|
||||
- **PostGIS storage**: `GisFeature` model with geometry column, SIRUTA-based partitioning, `enrichment` JSONB field
|
||||
- **Background sync**: long-running jobs via server singleton, progress polling (2s), phase tracking (fetch → save → enrich)
|
||||
- **Enrichment pipeline** (`enrich-service.ts`): hits eTerra `/api/immovable/list` per parcel to extract NR_CAD, NR_CF, PROPRIETARI, SUPRAFATA, INTRAVILAN, CATEGORIE_FOLOSINTA, HAS_BUILDING, etc.
|
||||
- **Owner search**: DB-first (ILIKE on enrichment JSON) with eTerra API fallback
|
||||
- **Per-UAT dashboard**: SQL aggregates (area stats, intravilan/extravilan, land use, top owners), CSS-only visualizations (donut ring, bar charts)
|
||||
- **Health check** (`eterra-health.ts`): pings `eterra.ancpi.ro` every 3min, detects maintenance by keywords in HTML response, blocks login when down, UI shows amber "Mentenanță" state
|
||||
- **Test UAT**: Feleacu (SIRUTA 57582, ~30k immovables, ~8k GIS features)
|
||||
|
||||
Key files:
|
||||
|
||||
- `services/eterra-client.ts` — API client (~1000 lines), session cache, pagination, retry
|
||||
- `services/eterra-layers.ts` — 23-layer catalog with categories
|
||||
- `services/sync-service.ts` — Layer sync engine with progress tracking
|
||||
- `services/enrich-service.ts` — Enrichment pipeline (FeatureEnrichment type)
|
||||
- `services/eterra-health.ts` — Health check singleton, maintenance detection
|
||||
- `services/session-store.ts` — Server-side session management
|
||||
- `components/parcel-sync-module.tsx` — Main UI (~4100 lines), 4 tabs (Export/Layers/Search/DB)
|
||||
- `components/uat-dashboard.tsx` — Per-UAT analytics dashboard (CSS-only charts)
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure
|
||||
|
||||
### Server: `10.10.10.166` (Ubuntu)
|
||||
|
||||
| Service | Port | Purpose |
|
||||
| ----------------------- | ---------------------- | ----------------------------------- |
|
||||
| **ArchiTools** | 3000 | This app (tools.beletage.ro) |
|
||||
| **Gitea** | 3002 | Git hosting (git.beletage.ro) |
|
||||
| **PostgreSQL** | 5432 | App database (Prisma ORM) |
|
||||
| **Portainer** | 9000 | Docker management |
|
||||
| **Nginx Proxy Manager** | 81 (admin) | Reverse proxy + SSL termination |
|
||||
| **Uptime Kuma** | 3001 | Service monitoring |
|
||||
| **MinIO** | 9002 (API) / 9003 (UI) | Object storage |
|
||||
| **Authentik** | 9100 | SSO (auth.beletage.ro) — **active** |
|
||||
| **N8N** | 5678 | Workflow automation (future) |
|
||||
| **Stirling PDF** | 8087 | PDF tools |
|
||||
| **IT-Tools** | 8085 | Developer utilities |
|
||||
| **FileBrowser** | 8086 | File management |
|
||||
| **Netdata** | 19999 | System monitoring |
|
||||
| **Dozzle** | 9999 | Docker log viewer |
|
||||
| **CrowdSec** | 8088 | Security |
|
||||
|
||||
### Deployment Pipeline
|
||||
|
||||
```
|
||||
git push origin main
|
||||
→ Gitea webhook fires
|
||||
→ Portainer CE detects new commit
|
||||
→ Manual "Pull and redeploy" in Portainer (CE doesn't auto-rebuild)
|
||||
→ Docker multi-stage build (~1-2 min)
|
||||
→ Container starts on :3000
|
||||
→ Nginx Proxy Manager routes to tools.beletage.ro
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
- `Dockerfile`: 3-stage build (deps → builder → runner), `node:20-alpine`, non-root user
|
||||
- `Dockerfile` includes `npx prisma generate` before build step
|
||||
- `docker-compose.yml`: single service, port 3000, **all env vars hardcoded** (Portainer CE can't inject env vars)
|
||||
- `output: 'standalone'` in `next.config.ts` is **required**
|
||||
- `@prisma/client` must be in `dependencies` (not devDependencies) for runtime
|
||||
|
||||
---
|
||||
|
||||
## Development Rules
|
||||
|
||||
### TypeScript Strict Mode Gotchas
|
||||
|
||||
- `array.split()[0]` returns `string | undefined` — use `.slice(0, 10)` instead
|
||||
- `Record<string, T>[key]` returns `T | undefined` — always guard with null check
|
||||
- Spread of possibly-undefined objects: `{ ...obj[key], field }` — check existence first
|
||||
- lucide-react Icons: cast through `unknown` → `React.ComponentType<{ className?: string }>`
|
||||
- `arr[0]` is `T | undefined` even after `arr.length > 0` check — assign to const first: `const first = arr[0]; if (first) { ... }`
|
||||
- Prisma `$queryRaw` returns `unknown[]` — always cast with `as Array<{ field: type }>` and guard access
|
||||
- `?? ""` on an object field typed `{}` produces `{}` not `string` — use explicit `typeof x === 'string'` or `'number'` check
|
||||
|
||||
### Conventions
|
||||
|
||||
- **Code**: English
|
||||
- **UI text**: Romanian
|
||||
- **Components**: functional, `'use client'` directive where needed
|
||||
- **State**: localStorage via `useStorage('module-name')` hook
|
||||
- **IDs**: `uuid v4`
|
||||
- **Dates**: ISO strings (`YYYY-MM-DD` for display, full ISO for timestamps)
|
||||
- **No emojis** in code or UI unless explicitly requested
|
||||
|
||||
### Storage Performance Rules
|
||||
|
||||
- **NEVER** use `storage.list()` followed by `storage.get()` in a loop — this is an N+1 query bug
|
||||
- `list()` fetches ALL items (keys+values) from DB but discards values, then each `get()` re-fetches individually
|
||||
- **ALWAYS** use `storage.exportAll()` (namespaced) or `storage.export(namespace)` (service-level) to batch-load
|
||||
- Filter items client-side after a single fetch: `for (const [key, value] of Object.entries(all)) { ... }`
|
||||
- After mutations (add/update), either do optimistic local state update or a single `refresh()` — never both
|
||||
- **NEVER store large binary data (base64 files) inside entity JSON** — this makes list loading transfer tens of MB
|
||||
- For modules with attachments: use `exportAll({ lightweight: true })` for listing, `storage.get()` for single-entry full load
|
||||
- The API `?lightweight=true` parameter strips `data`/`fileData` strings >1KB from JSON values server-side
|
||||
- Future: move file data to MinIO; only store metadata (name, size, type, url) in the entity JSON
|
||||
|
||||
### Module Development Pattern
|
||||
|
||||
Every module follows:
|
||||
|
||||
```
|
||||
src/modules/<name>/
|
||||
├── components/ # React components
|
||||
├── hooks/ # Custom hooks (use-<name>.ts)
|
||||
├── services/ # Business logic (pure functions)
|
||||
├── types.ts # TypeScript interfaces
|
||||
├── config.ts # ModuleConfig metadata
|
||||
└── index.ts # Public exports
|
||||
```
|
||||
|
||||
### eTerra / External API Rules
|
||||
|
||||
- **ArcGIS REST API** has `maxRecordCount=1000` — always paginate with `resultOffset`/`resultRecordCount`
|
||||
- **eTerra sessions expire after ~10min** — session cache TTL is 9min, auto-relogin on 401/redirect
|
||||
- **eTerra goes into maintenance regularly** — health check must detect and block login attempts
|
||||
- **Never hardcode timeouts too low** — eTerra 1000-feature geometry pages can take 60-90s; default is 120s
|
||||
- **CookieJar + axios-cookiejar-support** required for eTerra auth (JSESSIONID tracking)
|
||||
- **Page size fallbacks**: if 1000 fails, retry with 500, then 200
|
||||
|
||||
### Before Pushing
|
||||
|
||||
1. `npx next build` — must pass with zero errors
|
||||
2. Test the feature manually on `localhost:3000`
|
||||
3. Commit with descriptive message
|
||||
4. `git push origin main` — Portainer auto-deploys
|
||||
|
||||
---
|
||||
|
||||
## Company IDs
|
||||
|
||||
| ID | Name | Prefix |
|
||||
| ----------------- | --------------- | ------ |
|
||||
| `beletage` | Beletage | B |
|
||||
| `urban-switch` | Urban Switch | US |
|
||||
| `studii-de-teren` | Studii de Teren | SDT |
|
||||
| `group` | Grup | G |
|
||||
|
||||
---
|
||||
|
||||
## Current Integrations
|
||||
|
||||
| Feature | Status | Notes |
|
||||
| -------------------- | ---------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| **Authentik SSO** | ✅ Active | NextAuth v4 + OIDC, group→role/company mapping |
|
||||
| **PostgreSQL** | ✅ Active | Prisma ORM, `KeyValueStore` model, `/api/storage` route |
|
||||
| **MinIO** | Client configured | 10.10.10.166:9002, bucket `tools`, adapter pending |
|
||||
| **AI Chat API** | ✅ Multi-provider | `/api/ai-chat` — OpenAI/Claude/Ollama/demo; needs API key env |
|
||||
| **Vault Encryption** | ✅ Active | AES-256-GCM server-side, `/api/vault`, ENCRYPTION_SECRET env |
|
||||
| **ManicTime Sync** | ✅ Implemented | `/api/manictime` — bidirectional Tags.txt sync, needs SMB mount |
|
||||
| **NAS Paths** | ✅ Active | `\\newamun` (10.10.10.10), drives A/O/P/T, hostname+IP fallback, `src/config/nas-paths.ts` |
|
||||
| **eTerra ANCPI** | ✅ Active | ParcelSync module, `eterra-client.ts`, health check + maintenance detection |
|
||||
| **PostGIS** | ✅ Active | `GisFeature` model, geometry storage, spatial queries, used by ParcelSync |
|
||||
| **N8N automations** | Webhook URL configured | For notifications, backups, workflows |
|
||||
|
||||
---
|
||||
|
||||
## Model Recommendations
|
||||
|
||||
| Task Type | Claude | OpenAI | Google | Notes |
|
||||
| ----------------------------- | -------------- | ------------- | ---------------- | ----------------------------------------------- |
|
||||
| **Bug fixes, config** | Haiku 4.5 | GPT-4o-mini | Gemini 2.5 Flash | Fast, cheap |
|
||||
| **Features, tests, UI** | **Sonnet 4.6** | GPT-5.2 | Gemini 3 Flash | Best value — Opus-class quality at Sonnet price |
|
||||
| **New modules, architecture** | Opus 4.6 | GPT-5.3-Codex | Gemini 3 Pro | Complex multi-file, business logic |
|
||||
|
||||
**Default: Sonnet 4.6** for most work. See `ROADMAP.md` for per-task recommendations.
|
||||
|
||||
### Session Handoff Tips
|
||||
|
||||
- Read this `CLAUDE.md` first — it has all context
|
||||
- Read `ROADMAP.md` for the complete task list with dependencies
|
||||
- Check `docs/` for deep dives on specific systems
|
||||
- Check `src/modules/<name>/types.ts` before modifying any module
|
||||
- Always run `npx next build` before committing
|
||||
- Push to `main` → Portainer auto-deploys via Gitea webhook
|
||||
- The 16 docs in `docs/` total ~10,600 lines — search them for architecture questions
|
||||
|
||||
---
|
||||
|
||||
## Documentation Index
|
||||
|
||||
| Doc | Path | Content |
|
||||
| ------------------- | ------------------------------------------ | -------------------------------------------- |
|
||||
| System Architecture | `docs/architecture/SYSTEM-ARCHITECTURE.md` | Overall architecture, module platform design |
|
||||
| Module System | `docs/architecture/MODULE-SYSTEM.md` | Module registry, lifecycle, config format |
|
||||
| Feature Flags | `docs/architecture/FEATURE-FLAGS.md` | Flag system, env overrides |
|
||||
| Storage Layer | `docs/architecture/STORAGE-LAYER.md` | StorageService interface, adapters |
|
||||
| Tagging System | `docs/architecture/TAGGING-SYSTEM.md` | Cross-module tags |
|
||||
| Security & Roles | `docs/architecture/SECURITY-AND-ROLES.md` | Visibility, auth, roles |
|
||||
| Module Dev Guide | `docs/guides/MODULE-DEVELOPMENT.md` | How to create a new module |
|
||||
| HTML Integration | `docs/guides/HTML-TOOL-INTEGRATION.md` | Legacy tool migration |
|
||||
| UI Design System | `docs/guides/UI-DESIGN-SYSTEM.md` | Design tokens, component patterns |
|
||||
| Docker Deployment | `docs/guides/DOCKER-DEPLOYMENT.md` | Full Docker/Portainer/Nginx guide |
|
||||
| Coding Standards | `docs/guides/CODING-STANDARDS.md` | TS strict, naming, patterns |
|
||||
| Testing Strategy | `docs/guides/TESTING-STRATEGY.md` | Testing approach |
|
||||
| Configuration | `docs/guides/CONFIGURATION.md` | Env vars, flags, companies |
|
||||
| Data Model | `docs/DATA-MODEL.md` | All entity schemas |
|
||||
| Repo Structure | `docs/REPO-STRUCTURE.md` | Directory layout |
|
||||
| Prompt Generator | `docs/modules/PROMPT-GENERATOR.md` | Prompt module deep dive |
|
||||
# ArchiTools — Project Context for AI Assistants
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # http://localhost:3000
|
||||
npx next build # verify zero errors before pushing
|
||||
git push origin main # manual redeploy via Portainer UI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Overview
|
||||
|
||||
**ArchiTools** is a modular internal web dashboard for 3 architecture/engineering companies:
|
||||
**Beletage** (architecture), **Urban Switch** (urbanism), **Studii de Teren** (geotechnics).
|
||||
Production: `tools.beletage.ro` — Docker on-premise, Portainer CE, Traefik v3 proxy.
|
||||
|
||||
### Stack
|
||||
|
||||
| Layer | Technology |
|
||||
| ---------- | ------------------------------------------------------- |
|
||||
| Framework | Next.js 16.x, App Router, TypeScript (strict) |
|
||||
| Styling | Tailwind CSS v4, shadcn/ui |
|
||||
| Database | PostgreSQL + PostGIS via Prisma v6 ORM |
|
||||
| Storage | `DatabaseStorageAdapter` (Prisma), localStorage fallback |
|
||||
| Files | MinIO (S3-compatible object storage) |
|
||||
| Auth | NextAuth v4 + Authentik OIDC (auth.beletage.ro) |
|
||||
| Deploy | Docker multi-stage → Portainer CE → Traefik v3 + SSL |
|
||||
| Repo | Gitea at `git.beletage.ro/gitadmin/ArchiTools` |
|
||||
| Language | Code: **English**, UI: **Romanian** |
|
||||
|
||||
### Architecture Principles
|
||||
|
||||
- **Module platform** — each module isolated: own types/services/hooks/components
|
||||
- **Feature flags** gate loading (disabled = zero bundle cost)
|
||||
- **Storage abstraction** via `StorageService` interface + adapters
|
||||
- **Auth via Authentik SSO** — group → role/company mapping
|
||||
- **All entities** include `visibility` / `createdBy` from day one
|
||||
|
||||
---
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/(modules)/ # Route pages (thin wrappers)
|
||||
├── core/ # Platform: auth, storage, flags, tagging, i18n, theme
|
||||
├── modules/<name>/ # Module business logic (see MODULE-MAP.md)
|
||||
│ ├── components/ # UI components
|
||||
│ ├── hooks/ # Module hooks
|
||||
│ ├── services/ # Business logic
|
||||
│ ├── types.ts # Interfaces
|
||||
│ ├── config.ts # Module metadata
|
||||
│ └── index.ts # Public exports
|
||||
├── shared/components/ # ui/ (shadcn), layout/ (sidebar/header), common/
|
||||
├── config/ # modules.ts, flags.ts, navigation.ts, companies.ts
|
||||
docs/ # Architecture, guides, module deep-dives
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Modules (17 total)
|
||||
|
||||
| Module | Route | Key Features |
|
||||
| ------------------ | ------------------- | --------------------------------------------------- |
|
||||
| Dashboard | `/` | KPI cards, activity feed, module grid |
|
||||
| Email Signature | `/email-signature` | Multi-company, live preview, copy/download |
|
||||
| Word XML | `/word-xml` | Category-based XML, simple/advanced, ZIP export |
|
||||
| Registratura | `/registratura` | Registry CRUD, legal deadlines, notifications, NAS |
|
||||
| Tag Manager | `/tag-manager` | Tags CRUD, ManicTime sync |
|
||||
| IT Inventory | `/it-inventory` | Equipment, rack visualization, filters |
|
||||
| Address Book | `/address-book` | Contacts, vCard, Registratura integration |
|
||||
| Password Vault | `/password-vault` | AES-256-GCM encrypted, WiFi QR, multi-user |
|
||||
| Mini Utilities | `/mini-utilities` | 12+ tools: PDF compress, OCR, converters, calc |
|
||||
| Prompt Generator | `/prompt-generator` | 18 templates, text + image targets |
|
||||
| Digital Signatures | `/digital-signatures` | Assets CRUD, file upload, tags |
|
||||
| Word Templates | `/word-templates` | Template library, .docx placeholder detection |
|
||||
| AI Chat | `/ai-chat` | Multi-provider (OpenAI/Claude/Ollama) |
|
||||
| Hot Desk | `/hot-desk` | 4 desks, week calendar, room layout |
|
||||
| ParcelSync | `/parcel-sync` | eTerra ANCPI, PostGIS, enrichment, ePay ordering |
|
||||
| Geoportal | `/geoportal` | MapLibre viewer, parcel search, UAT layers |
|
||||
| Visual CoPilot | `/visual-copilot` | Placeholder — separate repo |
|
||||
|
||||
See `docs/MODULE-MAP.md` for entry points, API routes, and cross-module deps.
|
||||
|
||||
---
|
||||
|
||||
## Development Rules
|
||||
|
||||
### TypeScript Strict Mode Gotchas
|
||||
|
||||
- `arr[0]` is `T | undefined` even after length check — assign to const first
|
||||
- `Record<string, T>[key]` returns `T | undefined` — always null-check
|
||||
- Spread of possibly-undefined: `{ ...obj[key] }` — check existence first
|
||||
- lucide-react Icons: cast through `unknown` → `React.ComponentType<{ className?: string }>`
|
||||
- Prisma `$queryRaw` returns `unknown[]` — cast with `as Array<{ field: type }>`
|
||||
- `?? ""` on `{}` field produces `{}` not `string` — use `typeof` check
|
||||
|
||||
### Conventions
|
||||
|
||||
- **Code**: English | **UI text**: Romanian | **IDs**: uuid v4
|
||||
- **Dates**: ISO strings (`YYYY-MM-DD` display, full ISO timestamps)
|
||||
- **Components**: functional, `'use client'` where needed
|
||||
- **No emojis** in code or UI
|
||||
|
||||
### Storage Performance (CRITICAL)
|
||||
|
||||
- **NEVER** `storage.list()` + `storage.get()` in loop — N+1 bug
|
||||
- **ALWAYS** use `storage.exportAll()` or `storage.export(namespace)` for batch-load
|
||||
- **NEVER** store base64 files in entity JSON — use `lightweight: true` for listing
|
||||
- After mutations: optimistic update OR single `refresh()` — never both
|
||||
|
||||
### Middleware & Large Uploads
|
||||
|
||||
- Middleware buffers entire body — exclude large-upload routes from matcher
|
||||
- Excluded routes: `api/auth|api/notifications/digest|api/compress-pdf|api/address-book|api/projects`
|
||||
- Excluded routes use `requireAuth()` from `auth-check.ts` instead
|
||||
- To add new upload route: (1) exclude from middleware, (2) add `requireAuth()`
|
||||
|
||||
### eTerra / ANCPI Rules
|
||||
|
||||
- ArcGIS: paginate with `resultOffset`/`resultRecordCount` (max 1000)
|
||||
- Sessions expire ~10min — cache TTL 9min, auto-relogin on 401
|
||||
- Health check detects maintenance — block login when down
|
||||
- `WORKSPACE_TO_COUNTY` (42 entries in `county-refresh.ts`) is authoritative
|
||||
- `GisUat.geometry` is huge — always `select` to exclude in list queries
|
||||
- Feature counts cached 5-min TTL
|
||||
- ePay: form-urlencoded body, OpenAM auth, MinIO metadata must be ASCII
|
||||
|
||||
### Before Pushing
|
||||
|
||||
1. `npx next build` — zero errors
|
||||
2. Test on `localhost:3000`
|
||||
3. Commit with descriptive message
|
||||
4. `git push origin main` → manual Portainer redeploy
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls (Top 10)
|
||||
|
||||
1. **Middleware body buffering** — upload routes >10MB must be excluded from matcher
|
||||
2. **N+1 storage queries** — use `exportAll()`, never `list()` + `get()` loop
|
||||
3. **GisUat geometry in queries** — exclude with `select`, or 50ms → 5+ seconds
|
||||
4. **Enrichment data loss on re-sync** — upsert must preserve enrichment field
|
||||
5. **Ghostscript corrupts fonts** — use qpdf for PDF compression, never GS
|
||||
6. **eTerra timeout too low** — geometry pages need 60-90s; default 120s
|
||||
7. **Traefik 60s readTimeout** — must set 600s in static config for uploads
|
||||
8. **Portainer CE can't inject env vars** — all env in docker-compose.yml
|
||||
9. **`@prisma/client` in dependencies** (not devDeps) — runtime requirement
|
||||
10. **`output: 'standalone'`** in next.config.ts — required for Docker
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure Quick Reference
|
||||
|
||||
| Service | Address | Purpose |
|
||||
| ----------- | ------------------------ | -------------------------- |
|
||||
| App | 10.10.10.166:3000 | ArchiTools (tools.beletage.ro) |
|
||||
| PostgreSQL | 10.10.10.166:5432 | Database (Prisma) |
|
||||
| MinIO | 10.10.10.166:9002/9003 | Object storage |
|
||||
| Authentik | 10.10.10.166:9100 | SSO (auth.beletage.ro) |
|
||||
| Portainer | 10.10.10.166:9000 | Docker management |
|
||||
| Gitea | 10.10.10.166:3002 | Git (git.beletage.ro) |
|
||||
| Traefik | 10.10.10.199 | Reverse proxy + SSL |
|
||||
| N8N | 10.10.10.166:5678 | Workflow automation |
|
||||
| Stirling PDF | 10.10.10.166:8087 | PDF tools (needs env vars!) |
|
||||
|
||||
## Company IDs
|
||||
|
||||
| ID | Name | Prefix |
|
||||
| ----------------- | --------------- | ------ |
|
||||
| `beletage` | Beletage | B |
|
||||
| `urban-switch` | Urban Switch | US |
|
||||
| `studii-de-teren` | Studii de Teren | SDT |
|
||||
| `group` | Grup | G |
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
| Doc | Path |
|
||||
| ------------------- | ------------------------------------------ |
|
||||
| Module Map | `docs/MODULE-MAP.md` |
|
||||
| Architecture Quick | `docs/ARCHITECTURE-QUICK.md` |
|
||||
| System Architecture | `docs/architecture/SYSTEM-ARCHITECTURE.md` |
|
||||
| Module System | `docs/architecture/MODULE-SYSTEM.md` |
|
||||
| Feature Flags | `docs/architecture/FEATURE-FLAGS.md` |
|
||||
| Storage Layer | `docs/architecture/STORAGE-LAYER.md` |
|
||||
| Security & Roles | `docs/architecture/SECURITY-AND-ROLES.md` |
|
||||
| Module Dev Guide | `docs/guides/MODULE-DEVELOPMENT.md` |
|
||||
| Docker Deployment | `docs/guides/DOCKER-DEPLOYMENT.md` |
|
||||
| Coding Standards | `docs/guides/CODING-STANDARDS.md` |
|
||||
| Data Model | `docs/DATA-MODEL.md` |
|
||||
|
||||
For module-specific deep dives, see `docs/modules/`.
|
||||
|
||||
+26
-10
@@ -1,39 +1,55 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:20-alpine AS deps
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
# BuildKit cache mount keeps npm's global cache between builds —
|
||||
# subsequent npm ci only downloads changed/new packages instead of
|
||||
# re-fetching everything from the registry (~30-60s saving).
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci --ignore-scripts
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
# Copy prisma schema first — cached layer for prisma generate
|
||||
COPY prisma ./prisma
|
||||
RUN npx prisma generate
|
||||
|
||||
# Now copy the rest of the source
|
||||
COPY . .
|
||||
|
||||
# Build args for NEXT_PUBLIC_* vars (inlined at build time)
|
||||
ARG NEXT_PUBLIC_STORAGE_ADAPTER=database
|
||||
ARG NEXT_PUBLIC_APP_NAME=ArchiTools
|
||||
ARG NEXT_PUBLIC_APP_URL=https://tools.beletage.ro
|
||||
ARG NEXT_PUBLIC_MARTIN_URL=https://tools.beletage.ro/tiles
|
||||
ARG NEXT_PUBLIC_PMTILES_URL=
|
||||
ENV NEXT_PUBLIC_STORAGE_ADAPTER=${NEXT_PUBLIC_STORAGE_ADAPTER}
|
||||
ENV NEXT_PUBLIC_APP_NAME=${NEXT_PUBLIC_APP_NAME}
|
||||
ENV NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
|
||||
ENV NEXT_PUBLIC_MARTIN_URL=${NEXT_PUBLIC_MARTIN_URL}
|
||||
ENV NEXT_PUBLIC_PMTILES_URL=${NEXT_PUBLIC_PMTILES_URL}
|
||||
|
||||
# Generate Prisma client before building
|
||||
RUN npx prisma generate
|
||||
# Increase memory for Next.js build if VM has limited RAM
|
||||
ENV NODE_OPTIONS="--max-old-space-size=2048"
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV TZ=Europe/Bucharest
|
||||
|
||||
RUN apk add --no-cache gdal gdal-tools ghostscript qpdf
|
||||
# Install system deps + create user in a single layer
|
||||
RUN apk add --no-cache gdal gdal-tools ghostscript qpdf tzdata \
|
||||
&& addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 nextjs
|
||||
|
||||
# Note: DWG→DXF conversion handled by dwg2dxf sidecar container (see docker-compose.yml)
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
+35
-9
@@ -32,17 +32,17 @@
|
||||
|
||||
| # | Module | Version | Status | Remaining Gaps | Future Enhancements |
|
||||
| --- | ------------------ | ------- | --------- | --------------------------------------------------- | ------------------------------------------------- |
|
||||
| 1 | Registratura | 0.4.0 | HARDENING | Legal deadline workflow gaps, chain logic | Workflow automation, email integration, OCR |
|
||||
| 1 | Registratura | 0.5.0 | HARDENING | — | Workflow automation, OCR, print/PDF export |
|
||||
| 2 | Email Signature | 0.1.0 | COMPLETE | US/SDT addresses may need update | AD sync, branding packs, promo banners |
|
||||
| 3 | Word XML | 0.1.0 | COMPLETE | — | Schema validator, visual mapper |
|
||||
| 4 | Digital Signatures | 0.1.0 | COMPLETE | — | Permission layers, document insertion |
|
||||
| 5 | Password Vault | 0.3.0 | COMPLETE | — | Hardware key, rotation reminders, Passbolt |
|
||||
| 5 | Password Vault | 0.4.0 | COMPLETE | — | Hardware key, rotation reminders, Passbolt |
|
||||
| 6 | IT Inventory | 0.2.0 | COMPLETE | — | Network scan import |
|
||||
| 7 | Address Book | 0.1.1 | COMPLETE | — | Email sync, deduplication |
|
||||
| 8 | Prompt Generator | 0.2.0 | HARDENING | Bug fixes, new idea TBD | Prompt scoring, more image templates |
|
||||
| 9 | Word Templates | 0.1.0 | COMPLETE | No clause library; no Word generation | Diff compare, document generator |
|
||||
| 10 | Tag Manager | 0.2.0 | HARDENING | Logic/workflow fix, ERP API exposure needed | Smart suggestions |
|
||||
| 11 | Mini Utilities | 0.2.0 | COMPLETE | — | More converters, more tools TBD |
|
||||
| 11 | Mini Utilities | 0.3.0 | COMPLETE | — | More converters, more tools TBD |
|
||||
| 12 | Dashboard | 0.1.0 | COMPLETE | — | Custom dashboards per role |
|
||||
| 13 | AI Chat | 0.2.0 | COMPLETE | Needs API key env vars for real AI | Streaming, model selector, conversation templates |
|
||||
| 14 | Hot Desk | 0.1.1 | COMPLETE | — | — |
|
||||
@@ -826,31 +826,57 @@ Env vars (hardcoded in docker-compose.yml for Portainer CE):
|
||||
|
||||
---
|
||||
|
||||
### 8.03 `[STANDARD]` Notification System
|
||||
### 8.03 ✅ `[STANDARD]` Notification System + Registratura UI Polish (2026-03-11)
|
||||
|
||||
**What:** Bell icon in header. Deadline alerts, overdue warnings, tacit approval triggers.
|
||||
**What:** Email notification system with daily digest via Brevo SMTP + N8N cron. Plus Registratura toolbar and registry number UX improvements.
|
||||
**Implemented:**
|
||||
- Brevo SMTP relay (nodemailer, port 587 STARTTLS), sender "Alerte Termene" <noreply@beletage.ro>
|
||||
- Daily digest email: urgent deadlines, overdue deadlines, expiring documents
|
||||
- Per-user notification preferences (3 types + global opt-out) stored in KeyValueStore
|
||||
- API routes: POST `/api/notifications/digest` (N8N Bearer auth), GET/PUT `/api/notifications/preferences` (session auth)
|
||||
- Test mode via `?test=true` query param on digest endpoint
|
||||
- "group" company users see ALL entries across companies in digest
|
||||
- UI: Bell icon button "Notificari" in Registratura toolbar → dialog with toggles
|
||||
- Icon-only toolbar buttons (Bune practici + Notificari) with native `title` tooltips
|
||||
- HTML email: inline-styled tables, color-coded rows (red/yellow/blue), per-company grouping
|
||||
- N8N cron: `0 8 * * 1-5` (weekdays 8:00)
|
||||
- **Compact registry numbers**: single-letter company badge (B=blue, U=violet, S=green, G=gray) + direction arrow (↓ green=intrat, ↑ orange=iesit) + plain number — `CompactNumber` component in `registry-table.tsx`
|
||||
**Files:** `src/core/notifications/`, `src/app/api/notifications/`, `components/notification-preferences.tsx`, `components/registry-table.tsx`
|
||||
|
||||
---
|
||||
|
||||
### 8.04 `[STANDARD]` Registratura — Print/PDF Export
|
||||
### 8.04 ✅ `[STANDARD]` Mini Utilities + Password Vault — UX Improvements (2026-03-12)
|
||||
|
||||
**What:** TVA calculator with configurable rate, new scale calculator for drawings, multi-user support in Password Vault.
|
||||
**Implemented:**
|
||||
- **TVA cotă configurabilă**: preseturi 5%/9%/19%/21% + câmp custom; rata efectivă afișată în rezultate; titlu card actualizat dinamic
|
||||
- **Calculator scară desen** (tab nou "Scară"): modul Real→Desen (input cm, output mm) și Desen→Real (input mm, output cm); 7 preseturi 1:20..1:5000 + scară custom 1:X; afișare secundară în m/cm; copy button pe rezultat
|
||||
- Logică: `drawing_mm = real_cm × 10 / scale` / `real_cm = drawing_mm × scale / 10`
|
||||
- **Password Vault — utilizatori multipli**: tip `VaultUser { username, password, email?, notes? }`; câmp `additionalUsers: VaultUser[]` pe `VaultEntry`; secțiune colapsibilă "Utilizatori suplimentari" în form; badge cu numărul de utilizatori în lista de intrări; normalizare backward-compat în `useVault` hook
|
||||
**Files:** `src/modules/mini-utilities/components/mini-utilities-module.tsx`, `src/modules/password-vault/types.ts`, `src/modules/password-vault/components/password-vault-module.tsx`, `src/modules/password-vault/hooks/use-vault.ts`
|
||||
**Versions:** Mini Utilities 0.2.0→0.3.0, Password Vault 0.3.0→0.4.0
|
||||
|
||||
---
|
||||
|
||||
### 8.05 `[STANDARD]` Registratura — Print/PDF Export
|
||||
|
||||
**What:** Export registry as formatted PDF. Options: full registry, single entry, deadline summary.
|
||||
|
||||
---
|
||||
|
||||
### 8.05 `[STANDARD]` Word Templates — Clause Library + Document Generator
|
||||
### 8.06 `[STANDARD]` Word Templates — Clause Library + Document Generator
|
||||
|
||||
**What:** In-app clause composition, template preview, simple Word generation from templates.
|
||||
|
||||
---
|
||||
|
||||
### 8.06 `[STANDARD]` N8N Webhook Integration
|
||||
### 8.07 `[STANDARD]` N8N Webhook Integration
|
||||
|
||||
**What:** Fire webhooks on events (new entry, deadline approaching, status change). N8N at http://10.10.10.166:5678.
|
||||
|
||||
---
|
||||
|
||||
### 8.07 `[STANDARD]` Mobile Responsiveness Audit
|
||||
### 8.08 `[STANDARD]` Mobile Responsiveness Audit
|
||||
|
||||
**What:** Test all modules on 375px/768px. Fix overflowing tables, forms, sidebar.
|
||||
|
||||
|
||||
+146
-68
@@ -1,68 +1,146 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
architools:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
- NEXT_PUBLIC_STORAGE_ADAPTER=${NEXT_PUBLIC_STORAGE_ADAPTER:-database}
|
||||
- NEXT_PUBLIC_APP_NAME=${NEXT_PUBLIC_APP_NAME:-ArchiTools}
|
||||
- NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-https://tools.beletage.ro}
|
||||
container_name: architools
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
# Database
|
||||
- DATABASE_URL=postgresql://architools_user:stictMyFon34!_gonY@10.10.10.166:5432/architools_db?schema=public
|
||||
# MinIO
|
||||
- MINIO_ENDPOINT=10.10.10.166
|
||||
- MINIO_PORT=9002
|
||||
- MINIO_USE_SSL=false
|
||||
- MINIO_ACCESS_KEY=admin
|
||||
- MINIO_SECRET_KEY=MinioStrongPass123
|
||||
- MINIO_BUCKET_NAME=tools
|
||||
# Authentication (Authentik OIDC)
|
||||
- NEXTAUTH_URL=https://tools.beletage.ro
|
||||
- NEXTAUTH_SECRET=8IL9Kpipj0EZwZPNvekbNRPhV6a2/UY4cGVzE3n0pUY=
|
||||
- AUTHENTIK_CLIENT_ID=V59GMiYle87yd9VZOgUmdSmzYQALqNsKVAUR6QMi
|
||||
- AUTHENTIK_CLIENT_SECRET=TMeewkusUro0hQ2DMwS0Z5lNpNMdmziO9WXywNAGlK3Y6Y8HYULZBEtMtm53lioIkszWbpPRQcv1cxHMtwftMvsaSnbliDsL1f707wmUJhMFKjeZ0ypIFKFG4dJkp7Jr
|
||||
- AUTHENTIK_ISSUER=https://auth.beletage.ro/application/o/architools/
|
||||
# Vault encryption
|
||||
- ENCRYPTION_SECRET=ArchiTools-Vault-2025!SecureKey@AES256
|
||||
# ManicTime Tags.txt sync (SMB mount path)
|
||||
- MANICTIME_TAGS_PATH=/mnt/manictime/Tags.txt
|
||||
# AI Chat (set AI_PROVIDER to openai/anthropic/ollama; demo if no key)
|
||||
- AI_PROVIDER=${AI_PROVIDER:-demo}
|
||||
- AI_API_KEY=${AI_API_KEY:-}
|
||||
- AI_MODEL=${AI_MODEL:-}
|
||||
- AI_BASE_URL=${AI_BASE_URL:-}
|
||||
- AI_MAX_TOKENS=${AI_MAX_TOKENS:-2048}
|
||||
# Visual CoPilot (at-vim)
|
||||
- VIM_URL=${VIM_URL:-}
|
||||
# eTerra ANCPI (parcel-sync module)
|
||||
- ETERRA_USERNAME=${ETERRA_USERNAME:-}
|
||||
- ETERRA_PASSWORD=${ETERRA_PASSWORD:-}
|
||||
# DWG-to-DXF sidecar
|
||||
- DWG2DXF_URL=http://dwg2dxf:5001
|
||||
depends_on:
|
||||
dwg2dxf:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
# SMB share for ManicTime Tags.txt (mount on host: //time/tags → /mnt/manictime)
|
||||
- /mnt/manictime:/mnt/manictime
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
|
||||
dwg2dxf:
|
||||
build:
|
||||
context: ./dwg2dxf-api
|
||||
container_name: dwg2dxf
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5001/health')"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
services:
|
||||
architools:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
- NEXT_PUBLIC_STORAGE_ADAPTER=${NEXT_PUBLIC_STORAGE_ADAPTER:-database}
|
||||
- NEXT_PUBLIC_APP_NAME=${NEXT_PUBLIC_APP_NAME:-ArchiTools}
|
||||
- NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-https://tools.beletage.ro}
|
||||
- NEXT_PUBLIC_MARTIN_URL=${NEXT_PUBLIC_MARTIN_URL}
|
||||
- NEXT_PUBLIC_PMTILES_URL=${NEXT_PUBLIC_PMTILES_URL}
|
||||
container_name: architools
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=${NODE_ENV}
|
||||
# Database
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
# MinIO
|
||||
- MINIO_ENDPOINT=${MINIO_ENDPOINT}
|
||||
- MINIO_PORT=${MINIO_PORT}
|
||||
- MINIO_USE_SSL=${MINIO_USE_SSL}
|
||||
- MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}
|
||||
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
|
||||
- MINIO_BUCKET_NAME=${MINIO_BUCKET_NAME}
|
||||
# Authentication (Authentik OIDC)
|
||||
- NEXTAUTH_URL=${NEXTAUTH_URL}
|
||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||||
- AUTHENTIK_CLIENT_ID=${AUTHENTIK_CLIENT_ID}
|
||||
- AUTHENTIK_CLIENT_SECRET=${AUTHENTIK_CLIENT_SECRET}
|
||||
- AUTHENTIK_ISSUER=${AUTHENTIK_ISSUER}
|
||||
# Vault encryption
|
||||
- ENCRYPTION_SECRET=${ENCRYPTION_SECRET}
|
||||
# ManicTime Tags.txt sync (SMB mount path)
|
||||
- MANICTIME_TAGS_PATH=${MANICTIME_TAGS_PATH}
|
||||
# AI Chat (set AI_PROVIDER to openai/anthropic/ollama; demo if no key)
|
||||
- AI_PROVIDER=${AI_PROVIDER:-demo}
|
||||
- AI_API_KEY=${AI_API_KEY:-}
|
||||
- AI_MODEL=${AI_MODEL:-}
|
||||
- AI_BASE_URL=${AI_BASE_URL:-}
|
||||
- AI_MAX_TOKENS=${AI_MAX_TOKENS:-2048}
|
||||
# Visual CoPilot (at-vim)
|
||||
- VIM_URL=${VIM_URL:-}
|
||||
# eTerra ANCPI (parcel-sync module)
|
||||
- ETERRA_USERNAME=${ETERRA_USERNAME:-}
|
||||
- ETERRA_PASSWORD=${ETERRA_PASSWORD:-}
|
||||
# ANCPI ePay (CF extract ordering)
|
||||
- ANCPI_USERNAME=${ANCPI_USERNAME}
|
||||
- ANCPI_PASSWORD=${ANCPI_PASSWORD}
|
||||
- ANCPI_BASE_URL=${ANCPI_BASE_URL}
|
||||
- ANCPI_LOGIN_URL=${ANCPI_LOGIN_URL}
|
||||
- ANCPI_DEFAULT_SOLICITANT_ID=${ANCPI_DEFAULT_SOLICITANT_ID}
|
||||
- MINIO_BUCKET_ANCPI=${MINIO_BUCKET_ANCPI}
|
||||
# Stirling PDF (local PDF tools)
|
||||
- STIRLING_PDF_URL=${STIRLING_PDF_URL}
|
||||
- STIRLING_PDF_API_KEY=${STIRLING_PDF_API_KEY}
|
||||
# iLovePDF cloud compression (free: 250 files/month)
|
||||
- ILOVEPDF_PUBLIC_KEY=${ILOVEPDF_PUBLIC_KEY:-}
|
||||
# Martin vector tile server (geoportal)
|
||||
- NEXT_PUBLIC_MARTIN_URL=${NEXT_PUBLIC_MARTIN_URL}
|
||||
# PMTiles overview tiles — proxied through tile-cache nginx (HTTPS, no mixed-content)
|
||||
- NEXT_PUBLIC_PMTILES_URL=${NEXT_PUBLIC_PMTILES_URL}
|
||||
# DWG-to-DXF sidecar
|
||||
- DWG2DXF_URL=${DWG2DXF_URL}
|
||||
# Email notifications (Brevo REST API)
|
||||
- BREVO_API_KEY=${BREVO_API_KEY}
|
||||
- NOTIFICATION_FROM_EMAIL=${NOTIFICATION_FROM_EMAIL}
|
||||
- NOTIFICATION_FROM_NAME=${NOTIFICATION_FROM_NAME}
|
||||
- NOTIFICATION_CRON_SECRET=${NOTIFICATION_CRON_SECRET}
|
||||
# Weekend Deep Sync email reports (comma-separated for multiple recipients)
|
||||
- WEEKEND_SYNC_EMAIL=${WEEKEND_SYNC_EMAIL:-}
|
||||
# PMTiles rebuild webhook (pmtiles-webhook systemd service on host)
|
||||
- N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-http://10.10.10.166:9876}
|
||||
# Portal-only users (comma-separated, redirected to /portal)
|
||||
- PORTAL_ONLY_USERS=${PORTAL_ONLY_USERS}
|
||||
# Address Book API (inter-service auth for external tools)
|
||||
- ADDRESSBOOK_API_KEY=${ADDRESSBOOK_API_KEY}
|
||||
depends_on:
|
||||
dwg2dxf:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
# SMB share for ManicTime Tags.txt (mount on host: //time/tags → /mnt/manictime)
|
||||
- /mnt/manictime:/mnt/manictime
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
|
||||
dwg2dxf:
|
||||
build:
|
||||
context: ./dwg2dxf-api
|
||||
container_name: dwg2dxf
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"python3",
|
||||
"-c",
|
||||
"import urllib.request; urllib.request.urlopen('http://localhost:5001/health')",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
martin:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: martin.Dockerfile
|
||||
container_name: martin
|
||||
restart: unless-stopped
|
||||
# No host port — only accessible via tile-cache nginx proxy
|
||||
command: ["--config", "/config/martin.yaml"]
|
||||
environment:
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
|
||||
tile-cache:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: tile-cache.Dockerfile
|
||||
container_name: tile-cache
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3010:80"
|
||||
depends_on:
|
||||
- martin
|
||||
volumes:
|
||||
- tile-cache-data:/var/cache/nginx/tiles
|
||||
|
||||
tippecanoe:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: tippecanoe.Dockerfile
|
||||
container_name: tippecanoe
|
||||
profiles: ["tools"]
|
||||
environment:
|
||||
- DB_HOST=${DB_HOST}
|
||||
- DB_PORT=${DB_PORT}
|
||||
- DB_NAME=${DB_NAME}
|
||||
- DB_USER=${DB_USER}
|
||||
- DB_PASS=${DB_PASS}
|
||||
- MINIO_ENDPOINT=${MINIO_ENDPOINT}
|
||||
- MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}
|
||||
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
|
||||
|
||||
volumes:
|
||||
tile-cache-data:
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# ArchiTools — Architecture Quick Reference
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
Browser → Traefik (tools.beletage.ro) → Next.js :3000
|
||||
├── App Router (pages)
|
||||
├── API Routes (/api/*)
|
||||
│ ├── Prisma → PostgreSQL + PostGIS
|
||||
│ ├── MinIO (file storage)
|
||||
│ ├── eTerra ANCPI (external GIS API)
|
||||
│ └── Brevo SMTP (email notifications)
|
||||
└── Auth: NextAuth → Authentik OIDC
|
||||
```
|
||||
|
||||
## Module Dependencies
|
||||
|
||||
```
|
||||
registratura ←→ address-book (bidirectional: contacts + reverse lookup)
|
||||
parcel-sync → geoportal (map components reuse)
|
||||
geoportal → PostGIS (spatial queries, vector tiles)
|
||||
parcel-sync → eTerra API (external: ANCPI cadastral data)
|
||||
parcel-sync → ePay API (external: ANCPI CF extract ordering)
|
||||
parcel-sync → MinIO (CF extract PDF storage)
|
||||
notifications → registratura (deadline digest data)
|
||||
all modules → core/storage (KeyValueStore via Prisma)
|
||||
all modules → core/auth (Authentik SSO session)
|
||||
```
|
||||
|
||||
## Critical API Routes (Write Operations)
|
||||
|
||||
| Route | Method | What it does | Auth |
|
||||
| ---------------------------------- | ------ | ----------------------------------- | --------- |
|
||||
| `/api/storage` | PUT/DELETE | KeyValueStore CRUD | Middleware |
|
||||
| `/api/registratura` | POST/PUT/DELETE | Registry entries + audit | Middleware + Bearer |
|
||||
| `/api/registratura/reserved` | POST | Reserve future registry slots | Middleware |
|
||||
| `/api/registratura/debug-sequences`| POST/PATCH | Reset sequence counters | Admin only |
|
||||
| `/api/vault` | PUT/DELETE | Encrypted vault entries | Middleware |
|
||||
| `/api/address-book` | PUT/DELETE | Contact CRUD | Middleware + Bearer |
|
||||
| `/api/eterra/sync-background` | POST | Start GIS sync job | Middleware |
|
||||
| `/api/eterra/uats` | POST/PATCH | UAT management + county refresh | Middleware |
|
||||
| `/api/ancpi/order` | POST | ePay CF extract order | Middleware |
|
||||
| `/api/notifications/digest` | POST | Trigger email digest | Bearer |
|
||||
| `/api/notifications/preferences` | PUT | User notification prefs | Middleware |
|
||||
| `/api/compress-pdf/*` | POST | PDF compression/unlock | requireAuth |
|
||||
|
||||
## Storage Architecture
|
||||
|
||||
```
|
||||
KeyValueStore (Prisma) GisFeature (PostGIS) MinIO
|
||||
├── namespace: module-id ├── layerId + objectId ├── bucket: tools
|
||||
├── key: entity UUID ├── geometry (GeoJSON) ├── bucket: ancpi-cf
|
||||
└── value: JSON blob ├── enrichment (JSONB) └── PDF files
|
||||
└── geom (native PostGIS)
|
||||
```
|
||||
|
||||
## Auth Flow
|
||||
|
||||
```
|
||||
User → /auth/signin → Authentik OIDC → callback → NextAuth session
|
||||
├── Middleware: checks JWT token, redirects if unauthenticated
|
||||
├── Portal-only users: env PORTAL_ONLY_USERS → redirected to /portal
|
||||
└── API routes excluded from middleware: use requireAuth() or Bearer token
|
||||
```
|
||||
|
||||
## Environment Variables (Critical)
|
||||
|
||||
| Var | Required | Used by |
|
||||
| ---------------------- | -------- | -------------------------- |
|
||||
| `DATABASE_URL` | Yes | Prisma |
|
||||
| `NEXTAUTH_SECRET` | Yes | NextAuth JWT |
|
||||
| `NEXTAUTH_URL` | Yes | Auth redirects |
|
||||
| `ENCRYPTION_SECRET` | Yes | Password Vault AES-256 |
|
||||
| `STIRLING_PDF_URL` | Yes | PDF compression/unlock |
|
||||
| `STIRLING_PDF_API_KEY` | Yes | Stirling PDF auth |
|
||||
| `NOTIFICATION_CRON_SECRET` | Yes | Digest endpoint Bearer |
|
||||
| `MINIO_*` | Yes | MinIO connection |
|
||||
| `ANCPI_*` | For ePay | ePay CF ordering |
|
||||
| `ILOVEPDF_PUBLIC_KEY` | Optional | Cloud PDF compression |
|
||||
| `PORTAL_ONLY_USERS` | Optional | Comma-separated usernames |
|
||||
@@ -443,6 +443,40 @@ interface WordTemplate extends BaseEntity {
|
||||
}
|
||||
```
|
||||
|
||||
### Email Notifications (platform service)
|
||||
|
||||
```typescript
|
||||
// src/core/notifications/types.ts
|
||||
|
||||
type NotificationType = "deadline-urgent" | "deadline-overdue" | "document-expiry";
|
||||
|
||||
interface NotificationPreference {
|
||||
userId: string;
|
||||
email: string;
|
||||
name: string;
|
||||
company: CompanyId;
|
||||
enabledTypes: NotificationType[];
|
||||
globalOptOut: boolean;
|
||||
}
|
||||
|
||||
interface DigestItem {
|
||||
entryNumber: string;
|
||||
subject: string;
|
||||
label: string;
|
||||
dueDate: string; // YYYY-MM-DD
|
||||
daysRemaining: number; // negative = overdue
|
||||
color: "red" | "yellow" | "blue";
|
||||
}
|
||||
|
||||
interface DigestSection {
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
items: DigestItem[];
|
||||
}
|
||||
```
|
||||
|
||||
> **Storage:** Preferences stored in `KeyValueStore` (namespace `notifications`, key `pref:<userId>`). No separate Prisma model needed.
|
||||
|
||||
---
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# ArchiTools — Module Map
|
||||
|
||||
Quick reference: entry points, key files, API routes, and cross-module dependencies.
|
||||
|
||||
## Module Index
|
||||
|
||||
| Module | Entry Point | Config | Types |
|
||||
| ------ | ----------- | ------ | ----- |
|
||||
| [Dashboard](#dashboard) | `modules/dashboard/index.ts` | — | `types.ts` |
|
||||
| [Email Signature](#email-signature) | `modules/email-signature/index.ts` | `config.ts` | `types.ts` |
|
||||
| [Word XML](#word-xml) | `modules/word-xml/index.ts` | `config.ts` | `types.ts` |
|
||||
| [Registratura](#registratura) | `modules/registratura/index.ts` | `config.ts` | `types.ts` |
|
||||
| [Tag Manager](#tag-manager) | `modules/tag-manager/index.ts` | `config.ts` | `types.ts` |
|
||||
| [IT Inventory](#it-inventory) | `modules/it-inventory/index.ts` | `config.ts` | `types.ts` |
|
||||
| [Address Book](#address-book) | `modules/address-book/index.ts` | `config.ts` | `types.ts` |
|
||||
| [Password Vault](#password-vault) | `modules/password-vault/index.ts` | `config.ts` | `types.ts` |
|
||||
| [Mini Utilities](#mini-utilities) | `modules/mini-utilities/index.ts` | `config.ts` | `types.ts` |
|
||||
| [Prompt Generator](#prompt-generator) | `modules/prompt-generator/index.ts` | `config.ts` | `types.ts` |
|
||||
| [Digital Signatures](#digital-signatures) | `modules/digital-signatures/index.ts` | `config.ts` | `types.ts` |
|
||||
| [Word Templates](#word-templates) | `modules/word-templates/index.ts` | `config.ts` | `types.ts` |
|
||||
| [AI Chat](#ai-chat) | `modules/ai-chat/index.ts` | `config.ts` | `types.ts` |
|
||||
| [Hot Desk](#hot-desk) | `modules/hot-desk/index.ts` | `config.ts` | `types.ts` |
|
||||
| [ParcelSync](#parcel-sync) | `modules/parcel-sync/index.ts` | `config.ts` | `types.ts` |
|
||||
| [Geoportal](#geoportal) | `modules/geoportal/index.ts` | `config.ts` | `types.ts` |
|
||||
| [Visual CoPilot](#visual-copilot) | `modules/visual-copilot/index.ts` | `config.ts` | — |
|
||||
|
||||
---
|
||||
|
||||
## Module Details
|
||||
|
||||
### Dashboard
|
||||
- **Route**: `/`
|
||||
- **Main component**: `app/(modules)/page.tsx` (home page, not a registered module)
|
||||
- **API routes**: none (reads via storage API)
|
||||
- **Cross-deps**: none
|
||||
|
||||
### Email Signature
|
||||
- **Route**: `/email-signature`
|
||||
- **Main component**: `components/email-signature-module.tsx`
|
||||
- **API routes**: none (client-only)
|
||||
- **Cross-deps**: none
|
||||
|
||||
### Word XML
|
||||
- **Route**: `/word-xml`
|
||||
- **Main component**: `components/word-xml-module.tsx`
|
||||
- **Services**: `services/xml-builder.ts`, `services/zip-export.ts`
|
||||
- **API routes**: none (client-only)
|
||||
- **Cross-deps**: none
|
||||
|
||||
### Registratura
|
||||
- **Route**: `/registratura`
|
||||
- **Main component**: `components/registratura-module.tsx`
|
||||
- **Key services**: `services/registry-service.ts` (numbering, advisory locks), `services/working-days.ts` (Romanian holidays), `services/deadline-catalog.ts` (18 legal deadline types), `services/deadline-service.ts`
|
||||
- **API routes**: `/api/registratura` (CRUD + audit), `/api/registratura/reserved`, `/api/registratura/debug-sequences`, `/api/registratura/audit`, `/api/registratura/status-check`
|
||||
- **Cross-deps**: **address-book** (quick contact, reverse lookup), **notifications** (deadline digest)
|
||||
|
||||
### Tag Manager
|
||||
- **Route**: `/tag-manager`
|
||||
- **Main component**: `components/tag-manager-module.tsx`
|
||||
- **Services**: `services/manictime-sync.ts`
|
||||
- **API routes**: `/api/manictime`
|
||||
- **Cross-deps**: core/tagging
|
||||
|
||||
### IT Inventory
|
||||
- **Route**: `/it-inventory`
|
||||
- **Main component**: `components/it-inventory-module.tsx`
|
||||
- **API routes**: none (via storage API)
|
||||
- **Cross-deps**: none
|
||||
|
||||
### Address Book
|
||||
- **Route**: `/address-book`
|
||||
- **Main component**: `components/address-book-module.tsx`
|
||||
- **Services**: `services/vcard-export.ts`
|
||||
- **API routes**: `/api/address-book` (CRUD, Bearer token support)
|
||||
- **Cross-deps**: **registratura** (reverse lookup via `useRegistry`)
|
||||
|
||||
### Password Vault
|
||||
- **Route**: `/password-vault`
|
||||
- **Main component**: `components/password-vault-module.tsx`
|
||||
- **API routes**: `/api/vault` (AES-256-GCM encrypt/decrypt)
|
||||
- **Cross-deps**: none
|
||||
|
||||
### Mini Utilities
|
||||
- **Route**: `/mini-utilities`
|
||||
- **Main component**: `components/mini-utilities-module.tsx` (monolithic, tab-based)
|
||||
- **API routes**: `/api/compress-pdf/*` (local qpdf + cloud iLovePDF), `/api/compress-pdf/unlock`
|
||||
- **Cross-deps**: none
|
||||
|
||||
### Prompt Generator
|
||||
- **Route**: `/prompt-generator`
|
||||
- **Main component**: `components/prompt-generator-module.tsx`
|
||||
- **Services**: `services/prompt-templates.ts` (18 templates)
|
||||
- **API routes**: none (client-only)
|
||||
- **Cross-deps**: none
|
||||
|
||||
### Digital Signatures
|
||||
- **Route**: `/digital-signatures`
|
||||
- **Main component**: `components/digital-signatures-module.tsx`
|
||||
- **API routes**: none (via storage API)
|
||||
- **Cross-deps**: none
|
||||
|
||||
### Word Templates
|
||||
- **Route**: `/word-templates`
|
||||
- **Main component**: `components/word-templates-module.tsx`
|
||||
- **Services**: `services/docx-analyzer.ts`
|
||||
- **API routes**: none (via storage API)
|
||||
- **Cross-deps**: none
|
||||
|
||||
### AI Chat
|
||||
- **Route**: `/ai-chat`
|
||||
- **Main component**: `components/ai-chat-module.tsx`
|
||||
- **API routes**: `/api/ai-chat` (multi-provider proxy)
|
||||
- **Cross-deps**: tag-manager (project linking)
|
||||
|
||||
### Hot Desk
|
||||
- **Route**: `/hot-desk`
|
||||
- **Main component**: `components/hot-desk-module.tsx`
|
||||
- **Services**: `services/desk-layout.ts`
|
||||
- **API routes**: none (via storage API)
|
||||
- **Cross-deps**: none
|
||||
|
||||
### ParcelSync
|
||||
- **Route**: `/parcel-sync`
|
||||
- **Main component**: `components/parcel-sync-module.tsx` (~4100 lines, 5 tabs)
|
||||
- **Key services**: `services/eterra-client.ts` (~1000 lines, eTerra API), `services/sync-service.ts`, `services/enrich-service.ts`, `services/eterra-health.ts`, `services/epay-client.ts`, `services/epay-queue.ts`, `services/epay-storage.ts`, `services/no-geom-sync.ts`
|
||||
- **API routes**: `/api/eterra/*` (login, sync, search, features, UATs, health), `/api/ancpi/*` (order, test), `/api/geoportal/*` (search, boundaries, setup)
|
||||
- **Cross-deps**: **geoportal** (map components via map-tab.tsx), **MinIO** (CF extract PDFs), **PostGIS** (GisFeature, GisUat)
|
||||
|
||||
### Geoportal
|
||||
- **Route**: `/geoportal`
|
||||
- **Main component**: `components/geoportal-module.tsx`
|
||||
- **Key components**: `components/map-viewer.tsx` (MapLibre, PMTiles protocol), `components/basemap-switcher.tsx`, `components/selection-toolbar.tsx`, `components/feature-info-panel.tsx`
|
||||
- **Tile infrastructure**: Martin v1.4.0 (live MVT) -> nginx tile-cache (7d TTL) -> Traefik; PMTiles (z0-z18, MinIO) for pre-generated overview tiles
|
||||
- **Monitor page**: `/monitor` — nginx/Martin/PMTiles status, rebuild + warm-cache actions
|
||||
- **API routes**: `/api/geoportal/*` (search, boundary-check, uat-bounds, setup-views, monitor)
|
||||
- **Cross-deps**: **parcel-sync** (declared dependency — uses PostGIS data), **MinIO** (PMTiles storage), **N8N** (rebuild webhook)
|
||||
|
||||
### Visual CoPilot
|
||||
- **Route**: `/visual-copilot`
|
||||
- **Status**: Placeholder (iframe to separate repo `git.beletage.ro/gitadmin/vim`)
|
||||
- **API routes**: none
|
||||
- **Cross-deps**: none
|
||||
@@ -99,6 +99,11 @@ ArchiTools/
|
||||
│ │ │ ├── use-theme.ts # Hook for theme access
|
||||
│ │ │ ├── tokens.ts # Design token definitions
|
||||
│ │ │ └── index.ts # Public API
|
||||
│ │ ├── notifications/
|
||||
│ │ │ ├── types.ts # NotificationType, NotificationPreference, DigestSection
|
||||
│ │ │ ├── email-service.ts # Nodemailer transport singleton (Brevo SMTP)
|
||||
│ │ │ ├── notification-service.ts # runDigest(), buildCompanyDigest(), preference CRUD
|
||||
│ │ │ └── index.ts # Public API
|
||||
│ │ └── auth/
|
||||
│ │ ├── auth-provider.tsx # Auth context provider (stub)
|
||||
│ │ ├── use-auth.ts # Hook for auth state
|
||||
@@ -324,6 +329,8 @@ Platform core systems. These are infrastructure services used by all modules. Co
|
||||
|
||||
- **`theme/`** — Dark/light theme system. Provides the theme context, toggle hook, and design token definitions. Theme preference is persisted in storage. Tokens define colors, spacing, and typography values consumed by Tailwind and component styles.
|
||||
|
||||
- **`notifications/`** — Email notification service. Daily digest via Brevo SMTP relay (nodemailer, port 587 STARTTLS). Includes `runDigest()` orchestrator, `buildCompanyDigest()` for per-company deadline aggregation, `renderDigestHtml()` for inline-styled email, and preference CRUD via KeyValueStore (`notifications` namespace). API routes: POST `/api/notifications/digest` (N8N cron, Bearer auth), GET/PUT `/api/notifications/preferences` (user session auth).
|
||||
|
||||
- **`auth/`** — Authentication and authorization stub. Defines the `AuthContext` interface (`user`, `role`, `permissions`, `company`). Currently returns a default admin user. When Authentik SSO integration is implemented, this module will resolve real identity from OIDC tokens. The interface remains stable; only the provider implementation changes.
|
||||
|
||||
### `src/modules/`
|
||||
|
||||
@@ -430,7 +430,8 @@ ArchiTools runs alongside existing services on the internal network:
|
||||
|---------|-----------------|---------|
|
||||
| **Authentik** | Future SSO provider | User authentication and role assignment |
|
||||
| **MinIO** | Future storage adapter | Object/file storage for documents, signatures, templates |
|
||||
| **N8N** | Future webhook/API | Workflow automation (document processing, notifications) |
|
||||
| **N8N** | ✅ Active (cron) | Daily digest cron (`0 8 * * 1-5`), future: backups, workflows |
|
||||
| **Brevo SMTP** | ✅ Active | Email relay for notification digests (port 587, STARTTLS) |
|
||||
| **Gitea** | Development | Source code hosting |
|
||||
| **Stirling PDF** | Dashboard link | PDF manipulation (external tool link) |
|
||||
| **IT-Tools** | Dashboard link | Technical utilities (external tool link) |
|
||||
@@ -446,9 +447,11 @@ ArchiTools runs alongside existing services on the internal network:
|
||||
|
||||
**Storage integration (MinIO):** When the MinIO adapter is implemented, modules that manage files (Digital Signatures, Word Templates) will store binary assets in MinIO buckets while keeping metadata in the primary storage.
|
||||
|
||||
**Automation integration (N8N):** Modules can trigger N8N webhooks for automated workflows. Example: Registratura creates a new entry, triggering an N8N workflow that sends a notification or generates a document.
|
||||
**Automation integration (N8N):** N8N triggers scheduled workflows via API endpoints. Active: daily digest cron calls `POST /api/notifications/digest` with Bearer token auth. Future: document processing, backups.
|
||||
|
||||
**SSO integration (Authentik):** The auth stub will be replaced with an Authentik OIDC client. The middleware layer will validate tokens and populate `AuthContext`. No module code changes required.
|
||||
**Email notifications (Brevo SMTP):** Platform service in `src/core/notifications/`. Nodemailer transport singleton connects to Brevo SMTP relay. Sender: "Alerte Termene" <noreply@beletage.ro>. `runDigest()` loads all registry entries, groups by company, builds digest per subscriber filtering by their preference types (urgent, overdue, expiry), renders inline-styled HTML, sends via SMTP. Test mode via `?test=true` query param. "group" company users receive digest with ALL entries. Preferences stored in KeyValueStore (namespace `notifications`).
|
||||
|
||||
**SSO integration (Authentik):** Authentik OIDC provides user identity. NextAuth v4 JWT/session callbacks map Authentik groups to roles and companies. Notification preferences auto-refresh user email/name/company from session on each save.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -57,6 +57,34 @@ NEXT_PUBLIC_STORAGE_ADAPTER=localStorage
|
||||
# Example: NEXT_PUBLIC_FLAGS_OVERRIDE=module_ai_chat=true,module_password_vault=false
|
||||
NEXT_PUBLIC_FLAGS_OVERRIDE=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Email Notifications (Brevo SMTP)
|
||||
# -----------------------------------------------------------------------------
|
||||
# SMTP relay for daily digest emails (deadline alerts, document expiry)
|
||||
BREVO_SMTP_HOST=smtp-relay.brevo.com
|
||||
BREVO_SMTP_PORT=587
|
||||
BREVO_SMTP_USER= # Brevo SMTP login (from Brevo dashboard)
|
||||
BREVO_SMTP_PASS= # Brevo SMTP key (from Brevo dashboard)
|
||||
NOTIFICATION_FROM_EMAIL=noreply@beletage.ro
|
||||
NOTIFICATION_FROM_NAME=Alerte Termene
|
||||
NOTIFICATION_CRON_SECRET= # Random Bearer token for N8N → digest API auth
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# eTerra ANCPI (ParcelSync module — GIS data)
|
||||
# -----------------------------------------------------------------------------
|
||||
ETERRA_USERNAME= # eTerra login email
|
||||
ETERRA_PASSWORD= # eTerra login password
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# ANCPI ePay (CF extract ordering)
|
||||
# -----------------------------------------------------------------------------
|
||||
ANCPI_USERNAME= # ePay login email (separate from eTerra)
|
||||
ANCPI_PASSWORD= # ePay login password
|
||||
ANCPI_BASE_URL=https://epay.ancpi.ro/epay
|
||||
ANCPI_LOGIN_URL=https://oassl.ancpi.ro/openam/UI/Login
|
||||
ANCPI_DEFAULT_SOLICITANT_ID=14452 # Beletage persoana juridica
|
||||
MINIO_BUCKET_ANCPI=ancpi-documente # MinIO bucket for CF extract PDFs
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# External Services
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -200,13 +200,26 @@ NEXT_PUBLIC_STORAGE_ADAPTER=localStorage
|
||||
# MINIO_BUCKET=architools
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Authentication (future: Authentik SSO)
|
||||
# Authentication (Authentik SSO)
|
||||
# ──────────────────────────────────────────
|
||||
# AUTHENTIK_ISSUER=https://auth.internal
|
||||
# AUTHENTIK_CLIENT_ID=architools
|
||||
# AUTHENTIK_CLIENT_SECRET=<secret>
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Email Notifications (Brevo SMTP)
|
||||
# ──────────────────────────────────────────
|
||||
BREVO_SMTP_HOST=smtp-relay.brevo.com
|
||||
BREVO_SMTP_PORT=587
|
||||
BREVO_SMTP_USER=<brevo-login>
|
||||
BREVO_SMTP_PASS=<brevo-smtp-key>
|
||||
NOTIFICATION_FROM_EMAIL=noreply@beletage.ro
|
||||
NOTIFICATION_FROM_NAME=Alerte Termene
|
||||
NOTIFICATION_CRON_SECRET=<random-bearer-token>
|
||||
```
|
||||
|
||||
> **N8N cron setup:** Create a workflow with Cron node (`0 8 * * 1-5`), HTTP Request node (POST `https://tools.beletage.ro/api/notifications/digest`, header `Authorization: Bearer <NOTIFICATION_CRON_SECRET>`). The endpoint returns `{ success, totalEmails, errors, companySummary }`. Add `?test=true` query param to send a test digest with sample data.
|
||||
|
||||
### Variable Scoping Rules
|
||||
|
||||
| Prefix | Available In | Notes |
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# Geoportal Continuous Improvement — Mega Prompt
|
||||
|
||||
Use this prompt to start a new session focused on geoportal tile serving improvements.
|
||||
|
||||
---
|
||||
|
||||
## Context Prompt (copy-paste to Claude)
|
||||
|
||||
```
|
||||
Scopul acestei sesiuni este imbunatatirea continua a tile serving-ului pentru modulul Geoportal din ArchiTools.
|
||||
|
||||
Citeste aceste fisiere INAINTE de orice:
|
||||
- CLAUDE.md (project conventions)
|
||||
- geoportal/TILE-SERVER-EVALUATION.md (current architecture + roadmap)
|
||||
- src/modules/geoportal/components/map-viewer.tsx (MapLibre + PMTiles integration)
|
||||
- martin.yaml (Martin tile server config)
|
||||
- docker-compose.yml (infrastructure stack)
|
||||
- scripts/rebuild-overview-tiles.sh (PMTiles generation pipeline)
|
||||
- src/app/api/geoportal/monitor/route.ts (monitoring API)
|
||||
- src/app/(modules)/monitor/page.tsx (monitoring dashboard)
|
||||
|
||||
## Arhitectura curenta (2026-03-28):
|
||||
|
||||
Pipeline: Browser → PMTiles (MinIO, z0-z18, ~1-2 GB) | Martin (PostGIS) doar pentru gis_terenuri_status + gis_cladiri_status
|
||||
Cache: nginx tile-cache (7d TTL) in fata Martin | Browser cache 24h | PMTiles servit direct din MinIO
|
||||
|
||||
Stack:
|
||||
- PMTiles: overview.pmtiles pe MinIO (10.10.10.166:9002/tiles/overview.pmtiles)
|
||||
- nginx tile-cache: port 3010, proxy_cache 2GB, 7d TTL
|
||||
- Martin v1.4: port intern 3000, config baked in image, pool_size 8
|
||||
- tippecanoe Docker: one-shot rebuild, profiles: ["tools"]
|
||||
- N8N webhook: auto-rebuild dupa weekend deep sync
|
||||
|
||||
Rebuild PMTiles: ~45-60 min (565K+ features, z0-z18)
|
||||
Server: VM satra (10.10.10.166), 6 CPU, 16 GB RAM, Docker, Portainer CE
|
||||
|
||||
IMPORTANT:
|
||||
- NEXT_PUBLIC_* vars TREBUIE declarate ca ARG+ENV in Dockerfile (altfel webpack nu le vede)
|
||||
- Portainer CE nu monteaza fisiere din repo — bake configs in Docker images
|
||||
- Dupa schimbari la Dockerfile/NEXT_PUBLIC_: docker compose build --no-cache architools
|
||||
|
||||
Comenzi server (SSH bulibasa@10.10.10.166):
|
||||
cd /tmp/ArchiTools && git pull && docker compose --profile tools build tippecanoe && docker compose --profile tools run --rm tippecanoe
|
||||
docker compose build --no-cache architools && docker compose up -d architools
|
||||
bash /tmp/ArchiTools/scripts/warm-tile-cache.sh http://10.10.10.166:3010
|
||||
|
||||
Monitor dashboard: https://tools.beletage.ro/monitor
|
||||
N8N: http://n8n.beletage.ro (workflow "PMTiles Rebuild")
|
||||
|
||||
npx next build TREBUIE sa treaca dupa fiecare schimbare.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist periodic (lunar):
|
||||
|
||||
### 1. Check MLT Production Readiness
|
||||
```
|
||||
Verifica daca Martin suporta generare MLT din PostGIS (nu doar servire din MBTiles).
|
||||
Cauta:
|
||||
- Martin releases: https://github.com/maplibre/martin/releases
|
||||
- Martin MLT PR: https://github.com/maplibre/martin/pull/2512
|
||||
- PostGIS MLT: cauta "ST_AsMLT" in PostGIS development
|
||||
- MapLibre GL JS MLT: https://maplibre.org/maplibre-tile-spec/implementation-status/
|
||||
|
||||
Daca Martin poate genera MLT din PostGIS live:
|
||||
1. Testeaza pe un layer (gis_terenuri) cu encoding: "mlt" in map-viewer
|
||||
2. Compara tile sizes MVT vs MLT
|
||||
3. Daca merge, aplica pe toate layerele Martin
|
||||
|
||||
Status curent (2026-03-28): NU e viabil. Martin doar serveste MLT pre-generat, nu transcodeaza din PostGIS.
|
||||
```
|
||||
|
||||
### 2. mvt-rs Parallel Evaluation
|
||||
```
|
||||
Evalueaza mvt-rs ca alternativa Martin pentru deployment multi-tenant.
|
||||
|
||||
Prompt gata de folosit:
|
||||
|
||||
"Deployeaza mvt-rs v0.16+ in parallel cu Martin pe ArchiTools.
|
||||
|
||||
Context:
|
||||
- PostgreSQL: 10.10.10.166:5432, db architools_db, user architools_user
|
||||
- Martin actual: martin.yaml cu 9 surse PostGIS (EPSG:3844)
|
||||
- Docker stack: Portainer CE, Traefik v3
|
||||
- Scopul: per-layer access control pentru clienti externi
|
||||
|
||||
Steps:
|
||||
1. Adauga mvt-rs in docker-compose.yml pe port 3011
|
||||
2. Configureaza aceleasi layere ca martin.yaml
|
||||
3. Test: toate proprietatile apar in MVT? Performance vs Martin?
|
||||
4. Admin UI: creeaza user test, asigneaza permisiuni per layer
|
||||
5. Decision matrix: cand trecem de la Martin la mvt-rs
|
||||
|
||||
NU modifica setup-ul Martin existent. Evaluare paralela doar.
|
||||
mvt-rs repo: https://github.com/mvt-proj/mvt-rs
|
||||
Citeste CLAUDE.md si geoportal/TILE-SERVER-EVALUATION.md inainte."
|
||||
```
|
||||
|
||||
### 3. PMTiles Rebuild Optimization
|
||||
```
|
||||
Daca rebuild dureaza >60 min sau fisierul >3 GB:
|
||||
- Evalueaza tile-join pentru rebuild partial (doar layerul modificat)
|
||||
- Evalueaza --no-tile-size-limit vs --drop-densest-as-needed trade-off
|
||||
- Evalueaza split: un PMTiles per UAT sincronizat (rebuild doar orasul modificat)
|
||||
- Evalueaza cron nightly vs rebuild per sync event
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known Issues & Limitations
|
||||
- tippecanoe `--drop-densest-as-needed` poate pierde features in zone dense la zoom mic
|
||||
- PMTiles data e statica — parcele noi nu apar pana la rebuild
|
||||
- MinIO CORS headers necesita Range + Content-Range exposed
|
||||
- Martin `pool_size: 8` — nu creste fara upgrade PostgreSQL
|
||||
- Portainer CE nu injecteaza env vars la build — toate in docker-compose.yml
|
||||
@@ -0,0 +1,334 @@
|
||||
# Tile Server Evaluation — ArchiTools Geoportal (March 2026)
|
||||
|
||||
## Context
|
||||
|
||||
ArchiTools Geoportal serves vector tiles (MVT) from PostgreSQL 16 + PostGIS 3 via Martin.
|
||||
Data: ~330K GIS features (parcels, buildings, admin boundaries) in EPSG:3844 (Stereo70), growing to 1M+.
|
||||
Frontend: MapLibre GL JS 5.21, Next.js 16, Docker self-hosted via Portainer CE.
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
1. Martin v0.15.0 was running in **auto-discovery mode** — the existing `martin.yaml` config was never mounted
|
||||
2. Building labels (`cadastral_ref`) missing from MVT tiles despite the view exposing them
|
||||
3. Performance concerns at scale (330K -> 1M+ features)
|
||||
|
||||
---
|
||||
|
||||
## Solutions Evaluated (7 options + emerging tech)
|
||||
|
||||
### 1. Martin (Fix + Upgrade) — WINNER
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| Root cause | `martin.yaml` not mounted in docker-compose — Martin ran in auto-discovery mode |
|
||||
| Fix | Bake config into custom image via Dockerfile + upgrade v0.15 -> v1.4.0 |
|
||||
| Performance | Fastest tile server benchmarked (2-3x faster than #2 Tegola) |
|
||||
| EPSG:3844 | Native support via `default_srid: 3844` |
|
||||
| New in v1.4 | ZSTD compression, MLT format, materialized views, better logging |
|
||||
|
||||
**Status: IMPLEMENTED AND VERIFIED IN PRODUCTION** (2026-03-27)
|
||||
|
||||
### 2. pg_tileserv (CrunchyData)
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| Architecture | Go binary, zero-config, delegates ST_AsMVT to PostGIS |
|
||||
| Property control | Auto from schema + URL `?properties=` parameter |
|
||||
| Performance | 2-3x slower than Martin (Rechsteiner benchmark) |
|
||||
| EPSG:3844 | Supported (auto-reprojects via ST_Transform) |
|
||||
| Killer feature | Function-based sources (full SQL tile functions) |
|
||||
| Dealbreaker | View extent estimation bug (#156) affects all our views, development stagnant (last release Feb 2025) |
|
||||
|
||||
**Verdict: NO** — slower, buggy with views, stagnant development.
|
||||
|
||||
### 3. Tegola (Go-based)
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| Architecture | Go, TOML config, explicit per-layer SQL |
|
||||
| Performance | 2nd in benchmarks, but 2-3x slower than Martin |
|
||||
| Built-in cache | File, S3/MinIO, Redis — with seed/purge CLI |
|
||||
| EPSG:3844 | **NOT SUPPORTED** (only 3857/4326) — requires ST_Transform in every query |
|
||||
| Killer feature | Built-in tile seeding and cache purging |
|
||||
|
||||
**Verdict: NO** — EPSG:3844 not supported, dealbreaker for our data.
|
||||
|
||||
### 4. t-rex (Rust-based)
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| Status | **Abandoned/unmaintained** — no releases since 2023 |
|
||||
|
||||
**Verdict: NO** — dead project.
|
||||
|
||||
### 5. GeoJSON Direct from Next.js API
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| 330K features | ~270 MB uncompressed, 800 MB-1.4 GB browser memory |
|
||||
| Browser impact | 10-30s main thread freeze, mobile crash |
|
||||
| Pan/zoom | Full re-fetch on every viewport change, flickering |
|
||||
| Viable range | Only at zoom 16+ with <500 features in viewport |
|
||||
|
||||
**Verdict: NO** — does not scale beyond ~20K features.
|
||||
|
||||
### 6. PMTiles (Pre-generated)
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| Architecture | Single-file tile archive, HTTP Range Requests, no server needed |
|
||||
| Performance | ~5ms per tile (vs 200-2000ms for Martin on low-zoom) |
|
||||
| Property control | tippecanoe gives explicit include/exclude per property |
|
||||
| Update strategy | Full rebuild required (~3-7 min for 330K features) |
|
||||
| EPSG:3844 | Requires reprojection to 4326 via ogr2ogr before tippecanoe |
|
||||
| MinIO serving | Yes — direct HTTP Range Requests with CORS |
|
||||
|
||||
**Verdict: YES as hybrid complement** — excellent for static UAT overview layers (z0-z12), Martin for live detail.
|
||||
|
||||
### 7. Emerging Solutions
|
||||
|
||||
| Solution | Status | Relevance |
|
||||
|---|---|---|
|
||||
| **mvt-rs** (Rust) | v0.16.2, active | Admin UI, auth per layer, cache — good for multi-tenant |
|
||||
| **MLT format** | Stable Jan 2026 | 6x compression, 4x faster decode — Martin v1.3+ supports it |
|
||||
| **BBOX** | Maturing | Similar to Tegola performance, unified raster+vector |
|
||||
| **DuckDB tiles** | Early | Not PostGIS replacement, interesting for GeoParquet |
|
||||
| **FlatGeobuf** | Stable | Good for <100K features, not a tile replacement |
|
||||
|
||||
---
|
||||
|
||||
## Benchmark Reference (Rechsteiner, April 2025)
|
||||
|
||||
| Rank | Server | Language | Relative Speed |
|
||||
|---|---|---|---|
|
||||
| 1 | **Martin** | Rust | 1x (fastest) |
|
||||
| 2 | Tegola | Go | 2-3x slower |
|
||||
| 3 | BBOX | Rust | ~same as Tegola |
|
||||
| 4 | pg_tileserv | Go | ~4x slower |
|
||||
| 5 | TiPg | Python | Slower |
|
||||
| 6 | ldproxy | Java | 4-70x slower |
|
||||
|
||||
Source: [github.com/FabianRechsteiner/vector-tiles-benchmark](https://github.com/FabianRechsteiner/vector-tiles-benchmark)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Roadmap
|
||||
|
||||
### Phase 1: Martin Fix — DONE (2026-03-27)
|
||||
|
||||
Changes applied:
|
||||
- `martin.Dockerfile`: custom image that COPY-s `martin.yaml` into `/config/`
|
||||
- `docker-compose.yml`: Martin v0.15 -> v1.4.0, build from Dockerfile, `--config` flag
|
||||
- `martin.yaml`: comment updated to reflect v1.4
|
||||
- `map-viewer.tsx`: building labels layer activated (`cladiriLabel` at minzoom 16)
|
||||
|
||||
#### Deployment Lessons Learned
|
||||
|
||||
1. **Docker image tag format changed at v1.0**: old tags use `v` prefix (`v0.15.0`), new tags do not (`1.4.0`). The tag `ghcr.io/maplibre/martin:v1.4.0` does NOT exist — correct is `ghcr.io/maplibre/martin:1.4.0`.
|
||||
|
||||
2. **Portainer CE volume mount pitfall**: volume `./martin.yaml:/config/martin.yaml:ro` fails because Portainer deploys only the docker-compose.yml content, not the full git repo. Docker silently creates an empty directory instead of failing. Solution: bake config into a custom image with a 2-line Dockerfile:
|
||||
```dockerfile
|
||||
FROM ghcr.io/maplibre/martin:1.4.0
|
||||
COPY martin.yaml /config/martin.yaml
|
||||
```
|
||||
|
||||
3. **Martin config format is stable**: YAML format unchanged from v0.15 to v1.4 — `postgres.tables`, `connection_string`, `auto_publish`, `properties` map all work identically. No migration needed.
|
||||
|
||||
4. **PostGIS view geometry type**: Martin logs `UNKNOWN GEOMETRY TYPE` for all views — this is normal for nested views (`SELECT * FROM parent_view`). Views don't register specific geometry types in `geometry_columns`. Does not affect tile generation or property inclusion.
|
||||
|
||||
### Phase 2A: nginx Tile Cache — DONE (2026-03-27)
|
||||
|
||||
**Impact**: 10-100x faster on repeat requests, zero PostGIS load for cached tiles.
|
||||
|
||||
Changes applied:
|
||||
- `nginx/tile-cache.conf`: proxy_cache config with 2GB cache zone, 7-day TTL, stale serving
|
||||
- `tile-cache.Dockerfile`: bakes nginx config into custom image (Portainer CE pattern)
|
||||
- `docker-compose.yml`: `tile-cache` container, Martin no longer exposed on host
|
||||
- Gzip passthrough (Martin already compresses), browser caching via Cache-Control headers
|
||||
- CORS headers for cross-origin tile requests
|
||||
|
||||
### Phase 2B: PMTiles — DONE (2026-03-27)
|
||||
|
||||
**Impact**: Sub-10ms overview tiles, zero PostGIS load for z0-z18.
|
||||
|
||||
Changes applied:
|
||||
- `scripts/rebuild-overview-tiles.sh`: ogr2ogr export (3844->4326) + tippecanoe generation
|
||||
- PMTiles archive: z0-z18, ~1-2 GB, includes all terenuri, cladiri, UATs, and administrativ layers
|
||||
- `map-viewer.tsx`: pmtiles:// protocol registered on MapLibre, hybrid source switching
|
||||
- MinIO bucket `tiles` with public read + CORS for Range Requests
|
||||
- N8N webhook trigger for rebuild (via monitor page)
|
||||
- Monitor page (`/monitor`): rebuild + warm-cache actions with live status polling
|
||||
|
||||
### Phase 2C: MLT Format — DEFERRED
|
||||
|
||||
Martin v1.4 advertises MLT support, but it cannot generate MLT from PostGIS live queries.
|
||||
MLT generation requires pre-built tile archives (tippecanoe does not output MLT either).
|
||||
No actionable path until Martin or tippecanoe adds MLT output from PostGIS sources.
|
||||
|
||||
### Phase 2D: mvt-rs Evaluation — FUTURE (Multi-Tenant)
|
||||
|
||||
**Impact**: Built-in auth, admin UI, per-layer access control.
|
||||
**Effort**: 1-2 days for evaluation + migration.
|
||||
|
||||
Reserved for when external client access to the geoportal is needed.
|
||||
mvt-rs (v0.16.2+, Rust, Salvo framework) provides per-layer auth and admin UI.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Current Architecture (as of 2026-03-27)
|
||||
|
||||
Full tile-serving pipeline in production:
|
||||
|
||||
```
|
||||
PostGIS (EPSG:3844)
|
||||
|
|
||||
+--> Martin v1.4.0 (live MVT from 9 PostGIS views)
|
||||
| |
|
||||
| +--> tile-cache (nginx reverse proxy, 2GB disk, 7d TTL)
|
||||
| |
|
||||
| +--> Traefik (tools.beletage.ro/tiles)
|
||||
|
|
||||
+--> ogr2ogr (3844->4326) + tippecanoe (z0-z18)
|
||||
|
|
||||
+--> PMTiles archive (~1-2 GB)
|
||||
|
|
||||
+--> MinIO bucket "tiles" (HTTP Range Requests)
|
||||
|
|
||||
+--> MapLibre (pmtiles:// protocol)
|
||||
```
|
||||
|
||||
**Hybrid strategy**:
|
||||
- PMTiles serves pre-generated overview tiles (all zoom levels, all layers)
|
||||
- Martin serves live detail tiles (real-time PostGIS data)
|
||||
- nginx tile-cache sits in front of Martin to absorb repeat requests
|
||||
- Rebuild triggered via N8N webhook from the `/monitor` page
|
||||
|
||||
---
|
||||
|
||||
## Operational Commands
|
||||
|
||||
### Rebuild PMTiles
|
||||
|
||||
Trigger from the Monitor page (`/monitor` -> "Rebuild PMTiles" button), which sends a webhook to N8N.
|
||||
N8N runs `scripts/rebuild-overview-tiles.sh` on the server.
|
||||
|
||||
Manual rebuild (SSH to 10.10.10.166):
|
||||
```bash
|
||||
cd /path/to/architools
|
||||
bash scripts/rebuild-overview-tiles.sh
|
||||
```
|
||||
|
||||
### Warm nginx Cache
|
||||
|
||||
Trigger from the Monitor page (`/monitor` -> "Warm Cache" button).
|
||||
Pre-loads frequently accessed tiles into the nginx disk cache.
|
||||
|
||||
### Purge nginx Tile Cache
|
||||
|
||||
```bash
|
||||
docker exec tile-cache rm -rf /var/cache/nginx/tiles/*
|
||||
docker exec tile-cache nginx -s reload
|
||||
```
|
||||
|
||||
### Restart Martin (after PostGIS view changes)
|
||||
|
||||
```bash
|
||||
docker restart martin
|
||||
```
|
||||
|
||||
Martin caches source schema at startup — must restart after DDL changes to pick up new columns.
|
||||
|
||||
### Check PMTiles Status
|
||||
|
||||
```bash
|
||||
# Check file size and last modified in MinIO
|
||||
docker exec minio mc stat local/tiles/overview.pmtiles
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Technical Details
|
||||
|
||||
### Martin v1.4.0 Deployment Architecture
|
||||
|
||||
```
|
||||
Gitea repo (martin.yaml + martin.Dockerfile)
|
||||
-> Portainer CE builds custom image: FROM martin:1.4.0, COPY martin.yaml
|
||||
-> Container starts with --config /config/martin.yaml
|
||||
-> Reads DATABASE_URL from environment
|
||||
-> Serves 9 PostGIS view sources on port 3000
|
||||
-> Host maps 3010:3000
|
||||
-> Traefik proxies tools.beletage.ro/tiles -> host:3010
|
||||
```
|
||||
|
||||
**Critical**: Do NOT use volume mounts for config files in Portainer CE stacks.
|
||||
Always bake configs into custom images via Dockerfile COPY.
|
||||
|
||||
### Martin Config (validated compatible v0.15 through v1.4)
|
||||
|
||||
The `martin.yaml` at project root defines 9 sources with explicit properties.
|
||||
Config format unchanged from v0.15 to v1.4 — no migration needed.
|
||||
|
||||
Key config features used:
|
||||
- `auto_publish: false` — only explicitly listed sources are served
|
||||
- `default_srid: 3844` — all sources use Stereo70
|
||||
- `properties:` map per source — explicit column name + PostgreSQL type
|
||||
- `minzoom/maxzoom` per source — controls tile generation range
|
||||
- `bounds: [20.2, 43.5, 30.0, 48.3]` — approximate Romania extent
|
||||
|
||||
### Docker Image Tag Convention
|
||||
|
||||
Martin changed tag format at v1.0:
|
||||
- Pre-1.0: `ghcr.io/maplibre/martin:v0.15.0` (with `v` prefix)
|
||||
- Post-1.0: `ghcr.io/maplibre/martin:1.4.0` (no `v` prefix)
|
||||
- Also available: `latest`, `nightly`
|
||||
|
||||
### PostGIS View Chain
|
||||
|
||||
```
|
||||
GisFeature table (Prisma) -> gis_features view -> gis_terenuri / gis_cladiri / gis_administrativ
|
||||
-> gis_terenuri_status / gis_cladiri_status (with JOINs)
|
||||
GisUat table -> gis_uats_z0/z5/z8/z12 (with ST_SimplifyPreserveTopology)
|
||||
```
|
||||
|
||||
### MapLibre Layer Architecture
|
||||
|
||||
```
|
||||
Sources (Martin): gis_uats_z0, z5, z8, z12, administrativ, terenuri, cladiri
|
||||
Layers per source: fill + line + label (where applicable)
|
||||
Selection: Separate highlight layers on terenuri source
|
||||
Drawing: GeoJSON source for freehand/rect polygon
|
||||
Building labels: cladiriLabel layer, cadastral_ref at minzoom 16
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment Pitfalls (Discovered During Implementation)
|
||||
|
||||
1. **Portainer CE does not expose repo files to containers at runtime.** Volume mounts like `./file.conf:/etc/file.conf:ro` fail silently — Docker creates an empty directory. Always bake config files into custom images via Dockerfile COPY.
|
||||
|
||||
2. **Martin Docker tag format change at v1.0.** `v1.4.0` does not exist, `1.4.0` does. Always check [ghcr.io/maplibre/martin](https://github.com/maplibre/martin/pkgs/container/martin) for actual tags.
|
||||
|
||||
3. **Martin logs `UNKNOWN GEOMETRY TYPE` for PostGIS views.** This is normal — nested views don't register geometry types in `geometry_columns`. Does not affect functionality.
|
||||
|
||||
4. **Martin auto-discovery mode is unreliable for property inclusion.** Always use explicit config with `auto_publish: false` and per-source `properties:` definitions.
|
||||
|
||||
5. **Martin caches source schema at startup.** After PostGIS view DDL changes (e.g., adding columns to gis_features), Martin must be restarted to pick up new columns.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Martin Documentation](https://maplibre.org/martin/)
|
||||
- [Martin Releases](https://github.com/maplibre/martin/releases)
|
||||
- [Martin Container Registry](https://github.com/maplibre/martin/pkgs/container/martin)
|
||||
- [Vector Tiles Benchmark (Rechsteiner 2025)](https://github.com/FabianRechsteiner/vector-tiles-benchmark)
|
||||
- [PMTiles Specification](https://github.com/protomaps/PMTiles)
|
||||
- [tippecanoe (Felt)](https://github.com/felt/tippecanoe)
|
||||
- [MLT Format Announcement](https://maplibre.org/news/2026-01-23-mlt-release/)
|
||||
- [mvt-rs](https://github.com/mvt-proj/mvt-rs)
|
||||
- [pg_tileserv](https://github.com/CrunchyData/pg_tileserv)
|
||||
- [Tegola](https://github.com/go-spatial/tegola)
|
||||
- [Serving Vector Tiles Fast (Spatialists)](https://spatialists.ch/posts/2025/04/05-serving-vector-tiles-fast/)
|
||||
@@ -0,0 +1,292 @@
|
||||
# Skill: MapLibre GL JS Performance for Large GIS Datasets
|
||||
|
||||
## When to Use
|
||||
|
||||
When building web maps with MapLibre GL JS that display large spatial datasets (>10K features). Covers source type selection, layer optimization, label rendering, and client-side performance tuning.
|
||||
|
||||
---
|
||||
|
||||
## Source Type Decision Matrix
|
||||
|
||||
| Dataset Size | Recommended Source | Reason |
|
||||
|---|---|---|
|
||||
| <2K features | GeoJSON | Simple, full property access, smooth |
|
||||
| 2K-20K features | GeoJSON (careful) | Works but `setData()` updates lag 200-400ms |
|
||||
| 20K-100K features | Vector tiles (MVT) | GeoJSON causes multi-second freezes |
|
||||
| 100K+ features | Vector tiles (MVT) | GeoJSON crashes mobile, 1GB+ memory on desktop |
|
||||
| Static/archival | PMTiles | Pre-generated, ~5ms per tile, zero server load |
|
||||
|
||||
### GeoJSON Memory Profile
|
||||
|
||||
| Features (polygons, ~20 coords each) | JSON Size | Browser Memory | Load Time |
|
||||
|---|---|---|---|
|
||||
| 1K | 0.8 MB | ~50 MB | <1s |
|
||||
| 10K | 8 MB | ~200 MB | 1-3s |
|
||||
| 50K | 41 MB | ~600 MB | 5-15s freeze |
|
||||
| 100K | 82 MB | ~1.2 GB | 15-30s freeze |
|
||||
| 330K | 270 MB | ~1.5 GB+ | Crash |
|
||||
|
||||
The bottleneck is `JSON.stringify` on the main thread when data is transferred to the Web Worker for `geojson-vt` tiling.
|
||||
|
||||
---
|
||||
|
||||
## Vector Tile Source Configuration
|
||||
|
||||
### Zoom-Dependent Source Loading
|
||||
|
||||
Don't load data you don't need. Set `minzoom`/`maxzoom` on sources and layers:
|
||||
|
||||
```typescript
|
||||
// Source: only request tiles in useful zoom range
|
||||
map.addSource('parcels', {
|
||||
type: 'vector',
|
||||
tiles: ['https://tiles.example.com/parcels/{z}/{x}/{y}'],
|
||||
minzoom: 10, // don't request below z10
|
||||
maxzoom: 18, // server maxzoom (tiles overzoom beyond this)
|
||||
});
|
||||
|
||||
// Layer: only render when meaningful
|
||||
map.addLayer({
|
||||
id: 'parcels-fill',
|
||||
type: 'fill',
|
||||
source: 'parcels',
|
||||
'source-layer': 'parcels',
|
||||
minzoom: 13, // visible from z13 (even if source loads from z10)
|
||||
maxzoom: 20, // render up to z20 (overzooming tiles from z18)
|
||||
paint: { ... },
|
||||
});
|
||||
```
|
||||
|
||||
### Multiple Sources at Different Detail Levels
|
||||
|
||||
For large datasets, serve simplified versions at low zoom:
|
||||
|
||||
```typescript
|
||||
// Simplified overview (server: ST_Simplify, fewer properties)
|
||||
map.addSource('parcels-overview', {
|
||||
type: 'vector',
|
||||
tiles: ['https://tiles.example.com/parcels_simplified/{z}/{x}/{y}'],
|
||||
minzoom: 6, maxzoom: 14,
|
||||
});
|
||||
|
||||
// Full detail
|
||||
map.addSource('parcels-detail', {
|
||||
type: 'vector',
|
||||
tiles: ['https://tiles.example.com/parcels/{z}/{x}/{y}'],
|
||||
minzoom: 14, maxzoom: 18,
|
||||
});
|
||||
|
||||
// Layers with zoom handoff
|
||||
map.addLayer({
|
||||
id: 'parcels-overview-fill', source: 'parcels-overview',
|
||||
minzoom: 10, maxzoom: 14, // disappears at z14
|
||||
...
|
||||
});
|
||||
map.addLayer({
|
||||
id: 'parcels-detail-fill', source: 'parcels-detail',
|
||||
minzoom: 14, // appears at z14
|
||||
...
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Label Rendering Best Practices
|
||||
|
||||
### Text Labels on Polygons
|
||||
|
||||
```typescript
|
||||
map.addLayer({
|
||||
id: 'parcel-labels',
|
||||
type: 'symbol',
|
||||
source: 'parcels',
|
||||
'source-layer': 'parcels',
|
||||
minzoom: 16, // only show labels at high zoom
|
||||
layout: {
|
||||
'text-field': ['coalesce', ['get', 'cadastral_ref'], ''],
|
||||
'text-font': ['Noto Sans Regular'],
|
||||
'text-size': 10,
|
||||
'text-anchor': 'center',
|
||||
'text-allow-overlap': false, // prevent label collisions
|
||||
'text-max-width': 8, // wrap long labels (in ems)
|
||||
'text-optional': true, // label is optional — feature renders without it
|
||||
'symbol-placement': 'point', // placed at polygon centroid
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#1e3a5f',
|
||||
'text-halo-color': '#ffffff',
|
||||
'text-halo-width': 1, // readability on any background
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Performance Tips for Labels
|
||||
|
||||
- **`text-allow-overlap: false`** — essential for dense datasets, MapLibre auto-removes colliding labels
|
||||
- **`text-optional: true`** — allow symbol layer to show icon without text if text collides
|
||||
- **High `minzoom`** (16+) — labels are expensive to render, only show when meaningful
|
||||
- **`text-font`** — use fonts available in the basemap style. Custom fonts require glyph server.
|
||||
- **`symbol-sort-key`** — prioritize which labels show first (e.g., larger parcels)
|
||||
|
||||
```typescript
|
||||
layout: {
|
||||
'symbol-sort-key': ['*', -1, ['get', 'area_value']], // larger areas get priority
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Selection and Interaction Patterns
|
||||
|
||||
### Click Selection (single feature)
|
||||
|
||||
```typescript
|
||||
map.on('click', 'parcels-fill', (e) => {
|
||||
const feature = e.features?.[0];
|
||||
if (!feature) return;
|
||||
const props = feature.properties;
|
||||
|
||||
// Highlight via filter
|
||||
map.setFilter('selection-highlight', ['==', 'object_id', props.object_id]);
|
||||
});
|
||||
```
|
||||
|
||||
### queryRenderedFeatures for Box/Polygon Selection
|
||||
|
||||
```typescript
|
||||
// Rectangle selection
|
||||
const features = map.queryRenderedFeatures(
|
||||
[[x1, y1], [x2, y2]], // pixel bbox
|
||||
{ layers: ['parcels-fill'] }
|
||||
);
|
||||
|
||||
// Features are from rendered tiles — properties may be limited
|
||||
// For full properties, fetch from API by ID
|
||||
```
|
||||
|
||||
**Important:** `queryRenderedFeatures` only returns features currently rendered in the viewport tiles. Properties in MVT tiles may be a subset of the full database record. For detailed properties, use a separate API endpoint.
|
||||
|
||||
### Highlight Layer Pattern
|
||||
|
||||
Dedicated layer with dynamic filter for selection highlighting:
|
||||
|
||||
```typescript
|
||||
// Add once during map setup
|
||||
map.addLayer({
|
||||
id: 'selection-fill',
|
||||
type: 'fill',
|
||||
source: 'parcels',
|
||||
'source-layer': 'parcels',
|
||||
filter: ['==', 'object_id', '__NONE__'], // show nothing initially
|
||||
paint: { 'fill-color': '#f59e0b', 'fill-opacity': 0.5 },
|
||||
});
|
||||
|
||||
// Update filter on selection
|
||||
const ids = Array.from(selectedIds);
|
||||
map.setFilter('selection-fill',
|
||||
ids.length > 0
|
||||
? ['in', ['to-string', ['get', 'object_id']], ['literal', ids]]
|
||||
: ['==', 'object_id', '__NONE__']
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Basemap Management
|
||||
|
||||
### Multiple Basemap Support
|
||||
|
||||
Switching basemaps requires recreating the map (MapLibre limitation). Preserve view state:
|
||||
|
||||
```typescript
|
||||
const viewStateRef = useRef({ center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM });
|
||||
|
||||
// Save on every move
|
||||
map.on('moveend', () => {
|
||||
viewStateRef.current = {
|
||||
center: map.getCenter().toArray(),
|
||||
zoom: map.getZoom(),
|
||||
};
|
||||
});
|
||||
|
||||
// On basemap switch: destroy map, recreate with saved view state
|
||||
// All sources + layers must be re-added after style load
|
||||
```
|
||||
|
||||
### Raster Basemaps
|
||||
|
||||
```typescript
|
||||
const style: StyleSpecification = {
|
||||
version: 8,
|
||||
sources: {
|
||||
basemap: {
|
||||
type: 'raster',
|
||||
tiles: ['https://mt0.google.com/vt/lyrs=s&x={x}&y={y}&z={z}'],
|
||||
tileSize: 256,
|
||||
attribution: '© Google',
|
||||
},
|
||||
},
|
||||
layers: [{
|
||||
id: 'basemap', type: 'raster', source: 'basemap',
|
||||
minzoom: 0, maxzoom: 20,
|
||||
}],
|
||||
};
|
||||
```
|
||||
|
||||
### Vector Basemaps (OpenFreeMap, MapTiler)
|
||||
|
||||
```typescript
|
||||
// Style URL — includes all sources + layers
|
||||
const map = new maplibregl.Map({
|
||||
style: 'https://tiles.openfreemap.org/styles/liberty',
|
||||
});
|
||||
|
||||
// Hide unwanted built-in layers (e.g., admin boundaries you'll replace)
|
||||
for (const layer of map.getStyle().layers) {
|
||||
if (/boundar|admin/i.test(layer.id)) {
|
||||
map.setLayoutProperty(layer.id, 'visibility', 'none');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Checklist
|
||||
|
||||
### Server Side
|
||||
- [ ] Spatial index (GiST) on geometry column
|
||||
- [ ] Zoom-dependent simplified views for overview levels
|
||||
- [ ] `minzoom`/`maxzoom` per tile source to prevent pathological tiles
|
||||
- [ ] HTTP cache (nginx proxy_cache / Varnish) in front of tile server
|
||||
- [ ] PMTiles for static layers (no DB hit)
|
||||
- [ ] Exclude large geometry columns from list queries
|
||||
|
||||
### Client Side
|
||||
- [ ] Set `minzoom` on layers to avoid rendering at useless zoom levels
|
||||
- [ ] `text-allow-overlap: false` on all symbol layers
|
||||
- [ ] Use `text-optional: true` for labels
|
||||
- [ ] Don't add GeoJSON sources for >20K features
|
||||
- [ ] Use `queryRenderedFeatures` (not `querySourceFeatures`) for interaction
|
||||
- [ ] Preserve view state across basemap switches (ref, not state)
|
||||
- [ ] Debounce viewport-dependent API calls (search, feature loading)
|
||||
|
||||
### Memory Management
|
||||
- [ ] Remove unused sources/layers when switching views
|
||||
- [ ] Clear GeoJSON sources with `setData(emptyFeatureCollection)` before removing
|
||||
- [ ] Use `map.remove()` in cleanup (useEffect return)
|
||||
- [ ] Don't store large GeoJSON in React state (use refs)
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **GeoJSON `setData()` freezes main thread** — `JSON.stringify` runs synchronously for every update
|
||||
2. **`queryRenderedFeatures` returns simplified geometry** — don't use for area/distance calculations
|
||||
3. **Vector tile properties may be truncated** — tile servers can drop properties to fit tile size limits
|
||||
4. **Basemap switch requires full map recreation** — save/restore view state and re-add all overlay layers
|
||||
5. **`text-font` must match basemap fonts** — if using vector basemap, use its font stack; if raster, you need a glyph server
|
||||
6. **Popup/tooltip on dense data causes flicker** — debounce mousemove handlers
|
||||
7. **Large fill layers without `minzoom` tank performance** — 100K polygons at z0 is pathological
|
||||
8. **`map.setFilter` with huge ID lists is slow** — for >1000 selected features, consider a separate GeoJSON source
|
||||
9. **MapLibre CSS must be loaded manually in SSR frameworks** — inject `<link>` in `useEffect` or import statically
|
||||
10. **React strict mode double-mounts effects** — guard map initialization with ref check
|
||||
@@ -0,0 +1,272 @@
|
||||
# Skill: PMTiles Generation Pipeline from PostGIS
|
||||
|
||||
## When to Use
|
||||
|
||||
When you need to pre-generate vector tiles from PostGIS data for fast static serving. Ideal for overview/boundary layers that change infrequently, serving from S3/MinIO/CDN without a tile server, or eliminating database load for tile serving.
|
||||
|
||||
---
|
||||
|
||||
## Complete Pipeline
|
||||
|
||||
### Prerequisites
|
||||
|
||||
| Tool | Purpose | Install |
|
||||
|---|---|---|
|
||||
| ogr2ogr (GDAL) | PostGIS export + reprojection | `apt install gdal-bin` or Docker |
|
||||
| tippecanoe | MVT tile generation → PMTiles | `ghcr.io/felt/tippecanoe` Docker image |
|
||||
| mc (MinIO client) | Upload to MinIO/S3 | `brew install minio/stable/mc` |
|
||||
|
||||
### Step 1: Export from PostGIS
|
||||
|
||||
```bash
|
||||
# Single layer — FlatGeobuf is fastest for tippecanoe input
|
||||
ogr2ogr -f FlatGeobuf \
|
||||
-s_srs EPSG:3844 \ # source SRID (your data)
|
||||
-t_srs EPSG:4326 \ # tippecanoe REQUIRES WGS84
|
||||
parcels.fgb \
|
||||
"PG:host=10.10.10.166 dbname=mydb user=myuser password=mypass" \
|
||||
-sql "SELECT id, name, area, geom FROM my_view WHERE geom IS NOT NULL"
|
||||
|
||||
# Multiple layers in parallel
|
||||
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||
parcels.fgb "PG:..." -sql "SELECT ... FROM gis_terenuri" &
|
||||
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||
buildings.fgb "PG:..." -sql "SELECT ... FROM gis_cladiri" &
|
||||
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||
uats.fgb "PG:..." -sql "SELECT ... FROM gis_uats_z12" &
|
||||
wait
|
||||
```
|
||||
|
||||
**Why FlatGeobuf over GeoJSON:**
|
||||
- Binary columnar format — tippecanoe reads it 3-5x faster
|
||||
- No JSON parsing overhead
|
||||
- Streaming read (no need to load entire file in memory)
|
||||
- tippecanoe native support since v2.17+
|
||||
|
||||
### Step 2: Generate PMTiles with tippecanoe
|
||||
|
||||
```bash
|
||||
# Single layer
|
||||
tippecanoe \
|
||||
-o parcels.pmtiles \
|
||||
--name="Parcels" \
|
||||
--layer="parcels" \
|
||||
--minimum-zoom=6 \
|
||||
--maximum-zoom=15 \
|
||||
--base-zoom=15 \
|
||||
--drop-densest-as-needed \
|
||||
--extend-zooms-if-still-dropping \
|
||||
--detect-shared-borders \
|
||||
--simplification=10 \
|
||||
--hilbert \
|
||||
--force \
|
||||
parcels.fgb
|
||||
|
||||
# Multi-layer (combined file)
|
||||
tippecanoe \
|
||||
-o combined.pmtiles \
|
||||
--named-layer=parcels:parcels.fgb \
|
||||
--named-layer=buildings:buildings.fgb \
|
||||
--named-layer=uats:uats.fgb \
|
||||
--minimum-zoom=0 \
|
||||
--maximum-zoom=15 \
|
||||
--drop-densest-as-needed \
|
||||
--detect-shared-borders \
|
||||
--hilbert \
|
||||
--force
|
||||
```
|
||||
|
||||
#### Key tippecanoe Flags
|
||||
|
||||
| Flag | Purpose | When to Use |
|
||||
|---|---|---|
|
||||
| `--minimum-zoom=N` | Lowest zoom level | Always set |
|
||||
| `--maximum-zoom=N` | Highest zoom level (full detail) | Always set |
|
||||
| `--base-zoom=N` | Zoom where ALL features kept (no dropping) | Set to max-zoom |
|
||||
| `--drop-densest-as-needed` | Drop features in dense areas at low zoom | Large polygon datasets |
|
||||
| `--extend-zooms-if-still-dropping` | Auto-increase max zoom if needed | Safety net |
|
||||
| `--detect-shared-borders` | Prevent gaps between adjacent polygons | **Critical for parcels/admin boundaries** |
|
||||
| `--coalesce-densest-as-needed` | Merge small features at low zoom | Building footprints |
|
||||
| `--simplification=N` | Pixel tolerance for geometry simplification | Reduce tile size at low zoom |
|
||||
| `--hilbert` | Hilbert curve ordering | Better compression, always use |
|
||||
| `-y col1 -y col2` | Include ONLY these properties | Reduce tile size |
|
||||
| `-x col1 -x col2` | Exclude these properties | Remove large/unnecessary fields |
|
||||
| `--force` | Overwrite existing output | Scripts |
|
||||
| `--no-feature-limit` | No limit per tile | When density matters |
|
||||
| `--no-tile-size-limit` | No tile byte limit | When completeness matters |
|
||||
|
||||
#### Property Control
|
||||
|
||||
```bash
|
||||
# Include only specific properties (whitelist)
|
||||
tippecanoe -o out.pmtiles -y name -y area -y type parcels.fgb
|
||||
|
||||
# Exclude specific properties (blacklist)
|
||||
tippecanoe -o out.pmtiles -x raw_json -x internal_id parcels.fgb
|
||||
|
||||
# Zoom-dependent properties (different attributes per zoom)
|
||||
# Use tippecanoe-json format with per-feature "tippecanoe" key
|
||||
```
|
||||
|
||||
### Step 3: Upload to MinIO (Atomic Swap)
|
||||
|
||||
```bash
|
||||
# Upload to temp name first
|
||||
mc cp combined.pmtiles myminio/tiles/combined_new.pmtiles
|
||||
|
||||
# Atomic rename (zero-downtime swap)
|
||||
mc mv myminio/tiles/combined_new.pmtiles myminio/tiles/combined.pmtiles
|
||||
```
|
||||
|
||||
### Step 4: MinIO CORS Configuration
|
||||
|
||||
```bash
|
||||
# Required for browser-direct Range Requests
|
||||
mc admin config set myminio api cors_allow_origin="https://tools.beletage.ro"
|
||||
|
||||
# Or bucket policy for public read
|
||||
mc anonymous set download myminio/tiles
|
||||
```
|
||||
|
||||
MinIO CORS must expose Range/Content-Range headers:
|
||||
```json
|
||||
{
|
||||
"CORSRules": [{
|
||||
"AllowedOrigins": ["https://your-domain.com"],
|
||||
"AllowedMethods": ["GET", "HEAD"],
|
||||
"AllowedHeaders": ["Range", "If-None-Match"],
|
||||
"ExposeHeaders": ["Content-Range", "Content-Length", "ETag"],
|
||||
"MaxAgeSeconds": 3600
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MapLibre GL JS Integration
|
||||
|
||||
```bash
|
||||
npm install pmtiles
|
||||
```
|
||||
|
||||
```typescript
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import { Protocol } from 'pmtiles';
|
||||
|
||||
// Register ONCE at app initialization
|
||||
const protocol = new Protocol();
|
||||
maplibregl.addProtocol('pmtiles', protocol.tile);
|
||||
|
||||
// Add source to map
|
||||
map.addSource('my-tiles', {
|
||||
type: 'vector',
|
||||
url: 'pmtiles://https://minio.example.com/tiles/combined.pmtiles',
|
||||
});
|
||||
|
||||
// Add layers
|
||||
map.addLayer({
|
||||
id: 'parcels-fill',
|
||||
type: 'fill',
|
||||
source: 'my-tiles',
|
||||
'source-layer': 'parcels', // layer name from tippecanoe --layer or --named-layer
|
||||
minzoom: 10,
|
||||
maxzoom: 16,
|
||||
paint: { 'fill-color': '#22c55e', 'fill-opacity': 0.15 },
|
||||
});
|
||||
|
||||
// Cleanup on unmount
|
||||
maplibregl.removeProtocol('pmtiles');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hybrid Architecture (PMTiles + Live Tile Server)
|
||||
|
||||
```
|
||||
Zoom 0-14: PMTiles from MinIO (pre-generated, ~5ms, zero DB load)
|
||||
Zoom 14+: Martin from PostGIS (live, always-current, ~50-200ms)
|
||||
```
|
||||
|
||||
```typescript
|
||||
// PMTiles for overview
|
||||
map.addSource('overview', {
|
||||
type: 'vector',
|
||||
url: 'pmtiles://https://minio/tiles/overview.pmtiles',
|
||||
});
|
||||
|
||||
// Martin for detail
|
||||
map.addSource('detail', {
|
||||
type: 'vector',
|
||||
tiles: ['https://tiles.example.com/{source}/{z}/{x}/{y}'],
|
||||
minzoom: 14,
|
||||
maxzoom: 18,
|
||||
});
|
||||
|
||||
// Layers with zoom handoff
|
||||
map.addLayer({
|
||||
id: 'parcels-overview', source: 'overview', 'source-layer': 'parcels',
|
||||
minzoom: 6, maxzoom: 14, // PMTiles handles low zoom
|
||||
...
|
||||
});
|
||||
map.addLayer({
|
||||
id: 'parcels-detail', source: 'detail', 'source-layer': 'gis_terenuri',
|
||||
minzoom: 14, // Martin handles high zoom
|
||||
...
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rebuild Strategies
|
||||
|
||||
### Nightly Cron
|
||||
|
||||
```bash
|
||||
# crontab -e
|
||||
0 2 * * * /opt/scripts/rebuild-tiles.sh >> /var/log/tile-rebuild.log 2>&1
|
||||
```
|
||||
|
||||
### After Data Sync (webhook/API trigger)
|
||||
|
||||
```bash
|
||||
# Call from sync completion handler
|
||||
curl -X POST http://n8n:5678/webhook/rebuild-tiles
|
||||
```
|
||||
|
||||
### Partial Rebuild (single layer update)
|
||||
|
||||
```bash
|
||||
# Rebuild just parcels, then merge with existing layers
|
||||
tippecanoe -o parcels_new.pmtiles ... parcels.fgb
|
||||
tile-join -o combined_new.pmtiles --force \
|
||||
parcels_new.pmtiles \
|
||||
buildings_existing.pmtiles \
|
||||
uats_existing.pmtiles
|
||||
mc cp combined_new.pmtiles myminio/tiles/combined.pmtiles
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build Time Estimates
|
||||
|
||||
| Features | Type | Zoom Range | Time | Output Size |
|
||||
|---|---|---|---|---|
|
||||
| 500 | Polygons (UAT) | z0-z12 | <5s | 10-30 MB |
|
||||
| 100K | Polygons (buildings) | z12-z15 | 30-90s | 100-200 MB |
|
||||
| 330K | Polygons (parcels) | z6-z15 | 2-5 min | 200-400 MB |
|
||||
| 1M | Polygons (mixed) | z0-z15 | 8-15 min | 500 MB-1 GB |
|
||||
|
||||
tippecanoe is highly optimized and uses parallel processing.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **tippecanoe only accepts WGS84 (EPSG:4326)** — always reproject with ogr2ogr first
|
||||
2. **`--detect-shared-borders` is critical for parcels** — without it, gaps appear between adjacent polygons
|
||||
3. **GeoJSON input is slow** — use FlatGeobuf for 3-5x faster reads
|
||||
4. **No incremental updates** — must rebuild entire file (use `tile-join` for layer-level replacement)
|
||||
5. **MinIO needs CORS for browser-direct access** — Range + Content-Range headers must be exposed
|
||||
6. **Large properties bloat tile size** — use `-y`/`-x` flags to control what goes into tiles
|
||||
7. **`--no-tile-size-limit` can produce huge tiles** — use with `--drop-densest-as-needed` safety valve
|
||||
8. **Atomic upload prevents serving partial files** — always upload as temp name then rename
|
||||
@@ -0,0 +1,213 @@
|
||||
# Skill: Vector Tile Serving from PostGIS
|
||||
|
||||
## When to Use
|
||||
|
||||
When building a web map that serves vector tiles from PostgreSQL/PostGIS data. Applies to any project using MapLibre GL JS, Mapbox GL JS, or OpenLayers with MVT tiles from a spatial database.
|
||||
|
||||
---
|
||||
|
||||
## Core Architecture Decision
|
||||
|
||||
**Always use a dedicated tile server over GeoJSON for datasets >20K features.**
|
||||
|
||||
GeoJSON limits:
|
||||
- 20K polygons: visible jank on `setData()`, 200-400ms freezes
|
||||
- 50K polygons: multi-second freezes, 500MB+ browser memory
|
||||
- 100K+ polygons: crashes mobile browsers, 1-2GB memory on desktop
|
||||
- `JSON.stringify` runs on main thread — blocks UI proportional to data size
|
||||
|
||||
Vector tiles (MVT) solve this:
|
||||
- Only visible tiles loaded (~50-200KB per viewport)
|
||||
- Incremental pan/zoom (no re-fetch)
|
||||
- ~100-200MB client memory regardless of total dataset size
|
||||
- Works on mobile
|
||||
|
||||
---
|
||||
|
||||
## Tile Server Rankings (Rechsteiner Benchmark, April 2025)
|
||||
|
||||
| Rank | Server | Language | Speed | Notes |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **Martin** | Rust | 1x | Clear winner, 95-122ms range |
|
||||
| 2 | Tegola | Go | 2-3x slower | Only supports SRID 3857/4326 |
|
||||
| 3 | BBOX | Rust | ~same as Tegola | Unified raster+vector |
|
||||
| 4 | pg_tileserv | Go | ~4x slower | Zero-config but limited control |
|
||||
| 5 | TiPg | Python | Slower | Not for production scale |
|
||||
| 6 | ldproxy | Java | 4-70x slower | Enterprise/OGC compliance |
|
||||
|
||||
Source: [github.com/FabianRechsteiner/vector-tiles-benchmark](https://github.com/FabianRechsteiner/vector-tiles-benchmark)
|
||||
|
||||
---
|
||||
|
||||
## Martin: Best Practices
|
||||
|
||||
### Always use explicit config (not auto-discovery)
|
||||
|
||||
Auto-discovery can drop properties, misdetect SRIDs, and behave unpredictably with nested views.
|
||||
|
||||
```yaml
|
||||
postgres:
|
||||
connection_string: ${DATABASE_URL}
|
||||
default_srid: 3844 # your source SRID
|
||||
auto_publish: false # explicit sources only
|
||||
tables:
|
||||
my_layer:
|
||||
schema: public
|
||||
table: my_view_name
|
||||
geometry_column: geom
|
||||
srid: 3844
|
||||
bounds: [20.2, 43.5, 30.0, 48.3] # approximate extent
|
||||
minzoom: 10
|
||||
maxzoom: 18
|
||||
properties:
|
||||
object_id: text # explicit column name: pg_type
|
||||
name: text
|
||||
area: float8
|
||||
```
|
||||
|
||||
### Docker image tags
|
||||
|
||||
Martin changed tag format at v1.0:
|
||||
- Pre-1.0: `ghcr.io/maplibre/martin:v0.15.0` (with `v` prefix)
|
||||
- Post-1.0: `ghcr.io/maplibre/martin:1.4.0` (no `v` prefix)
|
||||
|
||||
### Docker deployment
|
||||
|
||||
**If your orchestrator has access to the full repo** (docker-compose CLI, Docker Swarm with repo checkout):
|
||||
```yaml
|
||||
martin:
|
||||
image: ghcr.io/maplibre/martin:1.4.0
|
||||
command: ["--config", "/config/martin.yaml"]
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://user:pass@host:5432/db
|
||||
volumes:
|
||||
- ./martin.yaml:/config/martin.yaml:ro
|
||||
ports:
|
||||
- "3010:3000"
|
||||
```
|
||||
|
||||
**If using Portainer CE or any system that only sees docker-compose.yml** (not full repo):
|
||||
Volume mounts for repo files fail silently — Docker creates an empty directory instead.
|
||||
Bake config into a custom image:
|
||||
|
||||
```dockerfile
|
||||
# martin.Dockerfile
|
||||
FROM ghcr.io/maplibre/martin:1.4.0
|
||||
COPY martin.yaml /config/martin.yaml
|
||||
```
|
||||
|
||||
```yaml
|
||||
martin:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: martin.Dockerfile
|
||||
command: ["--config", "/config/martin.yaml"]
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://user:pass@host:5432/db
|
||||
ports:
|
||||
- "3010:3000"
|
||||
```
|
||||
|
||||
### Custom SRID handling
|
||||
|
||||
Martin handles non-4326/3857 SRIDs natively. Set `default_srid` globally or `srid` per source. Martin reprojects to Web Mercator (3857) internally for tile envelope calculations. Your PostGIS spatial indexes on the source SRID are used correctly.
|
||||
|
||||
### Zoom-dependent simplification
|
||||
|
||||
Create separate views per zoom range with `ST_SimplifyPreserveTopology`:
|
||||
|
||||
```sql
|
||||
-- z0-5: heavy simplification (2000m tolerance)
|
||||
CREATE VIEW my_layer_z0 AS
|
||||
SELECT id, name, ST_SimplifyPreserveTopology(geom, 2000) AS geom
|
||||
FROM my_table;
|
||||
|
||||
-- z8-12: moderate (50m)
|
||||
CREATE VIEW my_layer_z8 AS
|
||||
SELECT id, name, ST_SimplifyPreserveTopology(geom, 50) AS geom
|
||||
FROM my_table;
|
||||
|
||||
-- z12+: full precision
|
||||
CREATE VIEW my_layer_z12 AS
|
||||
SELECT * FROM my_table;
|
||||
```
|
||||
|
||||
### Performance at 1M+ features
|
||||
|
||||
- Set `minzoom` per source to avoid pathological low-zoom tiles
|
||||
- Buildings: minzoom 14 (skip at overview levels)
|
||||
- Use zoom-dependent simplified views for boundaries
|
||||
- Add HTTP cache (nginx proxy_cache) in front of Martin
|
||||
- Consider PMTiles for static overview layers
|
||||
|
||||
---
|
||||
|
||||
## PMTiles: Pre-generated Tile Archives
|
||||
|
||||
Best for: static/rarely-changing layers, overview zoom levels, eliminating DB load.
|
||||
|
||||
### Pipeline
|
||||
|
||||
```bash
|
||||
# 1. Export from PostGIS, reproject to WGS84
|
||||
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||
layer.fgb "PG:dbname=mydb" \
|
||||
-sql "SELECT id, name, geom FROM my_table"
|
||||
|
||||
# 2. Generate PMTiles
|
||||
tippecanoe -o output.pmtiles \
|
||||
--layer="my_layer" layer.fgb \
|
||||
--minimum-zoom=0 --maximum-zoom=14 \
|
||||
--drop-densest-as-needed \
|
||||
--detect-shared-borders \
|
||||
--hilbert --force
|
||||
|
||||
# 3. Serve from any HTTP server with Range request support (MinIO, nginx, CDN)
|
||||
```
|
||||
|
||||
### MapLibre integration
|
||||
|
||||
```typescript
|
||||
import { Protocol } from 'pmtiles';
|
||||
maplibregl.addProtocol('pmtiles', new Protocol().tile);
|
||||
|
||||
// Add source
|
||||
map.addSource('overview', {
|
||||
type: 'vector',
|
||||
url: 'pmtiles://https://my-server/tiles/overview.pmtiles',
|
||||
});
|
||||
```
|
||||
|
||||
### Hybrid approach (recommended for large datasets)
|
||||
|
||||
- PMTiles for overview (z0-z14): pre-generated, ~5ms serving, zero DB load
|
||||
- Martin for detail (z14+): live from PostGIS, always-current data
|
||||
- Rebuild PMTiles on schedule (nightly) or after data sync
|
||||
|
||||
---
|
||||
|
||||
## MLT (MapLibre Tiles) — Next-Gen Format (2026)
|
||||
|
||||
- 6x better compression than MVT (column-oriented layout)
|
||||
- 3.7-4.4x faster client decode (SIMD-friendly)
|
||||
- Martin v1.3+ supports serving MLT
|
||||
- MapLibre GL JS 5.x supports decoding MLT
|
||||
- Spec: [github.com/maplibre/maplibre-tile-spec](https://github.com/maplibre/maplibre-tile-spec)
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Martin auto-discovery drops properties** — always use explicit config with `auto_publish: false`
|
||||
2. **Martin Docker tag format changed at v1.0** — `v0.15.0` (with v) but `1.4.0` (without v). Check actual tags at ghcr.io.
|
||||
3. **Portainer CE volume mounts fail silently** — Docker creates empty directory instead of file. Bake configs into images via Dockerfile COPY.
|
||||
4. **Martin logs `UNKNOWN GEOMETRY TYPE` for views** — normal for nested views, does not affect tile generation
|
||||
5. **Nested views lose SRID metadata** — cast geometry: `geom::geometry(Geometry, 3844)`
|
||||
6. **GisUat.geometry is huge** — always `select` to exclude in list queries
|
||||
7. **Low-zoom tiles scan entire dataset** — use zoom-dependent simplified views
|
||||
8. **No tile cache by default** — add nginx/Varnish in front of any tile server
|
||||
9. **tippecanoe requires WGS84** — reproject from custom SRID before generating PMTiles
|
||||
10. **PMTiles not incrementally updatable** — full rebuild required on data change
|
||||
11. **Tegola doesn't support custom SRIDs** — only 3857/4326, requires ST_Transform everywhere
|
||||
12. **pg_tileserv `ST_Estimated_Extent` fails on views** — use materialized views or function layers
|
||||
13. **Martin caches source schema at startup** — restart after view DDL changes
|
||||
@@ -1,456 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ro">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Configurator semnatura e-mail</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { font-family: 'Inter', sans-serif; }
|
||||
.no-select { -webkit-user-select: none; -ms-user-select: none; user-select: none; }
|
||||
|
||||
input[type=range] {
|
||||
-webkit-appearance: none; appearance: none; width: 100%; height: 4px;
|
||||
background: #e5e7eb; border-radius: 5px; outline: none; transition: background 0.2s ease;
|
||||
}
|
||||
input[type=range]:hover { background: #d1d5db; }
|
||||
input[type=range]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; appearance: none; width: 12px; height: 20px;
|
||||
background: #22B5AB; cursor: pointer; border-radius: 4px;
|
||||
margin-top: -8px; box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
transition: transform 0.1s ease-in-out, box-shadow 0.1s ease-in-out;
|
||||
}
|
||||
input[type=range]::-webkit-slider-thumb:active { transform: scale(1.15); box-shadow: 0 2px 6px rgba(0,0,0,0.3); }
|
||||
input[type=range]::-moz-range-thumb {
|
||||
width: 12px; height: 20px; background: #22B5AB; cursor: pointer;
|
||||
border-radius: 4px; border: none; box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
}
|
||||
#preview-wrapper { transition: transform 0.2s ease-in-out; transform-origin: top left; }
|
||||
.color-swatch {
|
||||
width: 24px; height: 24px; border-radius: 9999px; cursor: pointer;
|
||||
border: 2px solid transparent; transition: all 0.2s ease;
|
||||
}
|
||||
.color-swatch.active { border-color: #22B5AB; transform: scale(1.1); box-shadow: 0 0 0 2px white, 0 0 0 4px #22B5AB; }
|
||||
.collapsible-content { max-height: 0; overflow: hidden; transition: max-height 0.3s ease-out; }
|
||||
.collapsible-content.open { max-height: 1000px; /* Valoare mare pentru a permite extinderea */ }
|
||||
.collapsible-trigger svg { transition: transform 0.3s ease; }
|
||||
.collapsible-trigger.open svg { transform: rotate(90deg); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 text-gray-800 no-select">
|
||||
|
||||
<div class="container mx-auto p-4 md:p-8">
|
||||
<header class="text-center mb-10">
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-gray-900">Configurator semnatura e-mail</h1>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-8">
|
||||
|
||||
<!-- Panoul de control -->
|
||||
<aside class="lg:w-2/5 bg-white p-6 rounded-2xl shadow-lg border border-gray-200">
|
||||
<div id="controls">
|
||||
<!-- Secțiunea Date Personale -->
|
||||
<div class="mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-800 border-b pb-2 mb-4">Date Personale</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="input-prefix" class="block text-sm font-medium text-gray-700 mb-1">Titulatură (prefix)</label>
|
||||
<input type="text" id="input-prefix" value="arh." class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-teal-500 focus:border-teal-500">
|
||||
</div>
|
||||
<div>
|
||||
<label for="input-name" class="block text-sm font-medium text-gray-700 mb-1">Nume și Prenume</label>
|
||||
<input type="text" id="input-name" value="Marius TĂRĂU" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-teal-500 focus:border-teal-500">
|
||||
</div>
|
||||
<div>
|
||||
<label for="input-title" class="block text-sm font-medium text-gray-700 mb-1">Funcția</label>
|
||||
<input type="text" id="input-title" value="Arhitect • Beletage SRL" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-teal-500 focus:border-teal-500">
|
||||
</div>
|
||||
<div>
|
||||
<label for="input-phone" class="block text-sm font-medium text-gray-700 mb-1">Telefon (format 07xxxxxxxx)</label>
|
||||
<input type="tel" id="input-phone" value="0785123433" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-teal-500 focus:border-teal-500">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Culori Text (Collapsible) -->
|
||||
<div class="mb-4">
|
||||
<div class="collapsible-trigger flex justify-between items-center cursor-pointer border-b pb-2 mb-2">
|
||||
<h3 class="text-lg font-semibold text-gray-800">Culori Text</h3>
|
||||
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
|
||||
</div>
|
||||
<div class="collapsible-content">
|
||||
<div id="color-controls" class="space-y-2 pt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Secțiunea Stil & Aranjare (Collapsible) -->
|
||||
<div class="mb-4">
|
||||
<div class="collapsible-trigger flex justify-between items-center cursor-pointer border-b pb-2 mb-2">
|
||||
<h3 class="text-lg font-semibold text-gray-800">Stil & Aranjare</h3>
|
||||
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
|
||||
</div>
|
||||
<div class="collapsible-content">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-3 pt-2">
|
||||
<div>
|
||||
<label for="green-line-width" class="block text-sm font-medium text-gray-700 mb-2">Lungime linie verde (<span id="green-line-value">97</span>px)</label>
|
||||
<input id="green-line-width" type="range" min="50" max="300" value="97">
|
||||
</div>
|
||||
<div>
|
||||
<label for="section-spacing" class="block text-sm font-medium text-gray-700 mb-2">Spațiere vert. secțiuni (<span id="section-spacing-value">10</span>px)</label>
|
||||
<input id="section-spacing" type="range" min="0" max="30" value="10">
|
||||
</div>
|
||||
<div>
|
||||
<label for="logo-spacing" class="block text-sm font-medium text-gray-700 mb-2">Spațiere vert. Logo (<span id="logo-spacing-value">10</span>px)</label>
|
||||
<input id="logo-spacing" type="range" min="0" max="30" value="10">
|
||||
</div>
|
||||
<div>
|
||||
<label for="title-spacing" class="block text-sm font-medium text-gray-700 mb-2">Spațiere vert. funcție (<span id="title-spacing-value">2</span>px)</label>
|
||||
<input id="title-spacing" type="range" min="0" max="20" value="2">
|
||||
</div>
|
||||
<div>
|
||||
<label for="b-gutter-width" class="block text-sm font-medium text-gray-700 mb-2">Aliniere contact (<span id="b-gutter-value">13</span>px)</label>
|
||||
<input id="b-gutter-width" type="range" min="0" max="150" value="13">
|
||||
</div>
|
||||
<div>
|
||||
<label for="icon-text-spacing" class="block text-sm font-medium text-gray-700 mb-2">Spațiu Icon-Text (<span id="icon-text-spacing-value">5</span>px)</label>
|
||||
<input id="icon-text-spacing" type="range" min="-10" max="30" value="5">
|
||||
</div>
|
||||
<div>
|
||||
<label for="icon-vertical-pos" class="block text-sm font-medium text-gray-700 mb-2">Aliniere vert. iconițe (<span id="icon-vertical-value">1</span>px)</label>
|
||||
<input id="icon-vertical-pos" type="range" min="-10" max="10" value="1">
|
||||
</div>
|
||||
<div>
|
||||
<label for="motto-spacing" class="block text-sm font-medium text-gray-700 mb-2">Spațiere vert. motto (<span id="motto-spacing-value">3</span>px)</label>
|
||||
<input id="motto-spacing" type="range" min="0" max="20" value="3">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Opțiuni -->
|
||||
<div class="mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-800 border-b pb-2 mb-4">Opțiuni</h3>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center space-x-3 cursor-pointer">
|
||||
<input type="checkbox" id="reply-variant-checkbox" class="h-4 w-4 rounded border-gray-300 text-teal-600 focus:ring-teal-500">
|
||||
<span class="text-sm font-medium text-gray-700">Variantă simplă (fără logo/adresă)</span>
|
||||
</label>
|
||||
<label class="flex items-center space-x-3 cursor-pointer">
|
||||
<input type="checkbox" id="super-reply-variant-checkbox" class="h-4 w-4 rounded border-gray-300 text-teal-600 focus:ring-teal-500">
|
||||
<span class="text-sm font-medium text-gray-700">Super-simplă (doar nume/telefon)</span>
|
||||
</label>
|
||||
<label class="flex items-center space-x-3 cursor-pointer">
|
||||
<input type="checkbox" id="use-svg-checkbox" class="h-4 w-4 rounded border-gray-300 text-teal-600 focus:ring-teal-500">
|
||||
<span class="text-sm font-medium text-gray-700">Folosește imagini SVG (calitate maximă)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buton de Export -->
|
||||
<div class="mt-8 pt-6 border-t">
|
||||
<button id="export-btn" class="w-full bg-teal-600 text-white font-bold py-3 px-4 rounded-lg hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-teal-500 transition-all duration-300 ease-in-out transform hover:scale-105">
|
||||
Descarcă HTML
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Previzualizare Live -->
|
||||
<main class="lg:w-3/5 bg-white p-6 rounded-2xl shadow-lg border border-gray-200 overflow-hidden">
|
||||
<div class="flex justify-between items-center border-b pb-3 mb-4">
|
||||
<h2 class="text-2xl font-bold text-gray-900">Previzualizare Live</h2>
|
||||
<button id="zoom-btn" class="text-sm bg-gray-200 text-gray-700 px-3 py-1 rounded-md hover:bg-gray-300">Zoom 100%</button>
|
||||
</div>
|
||||
<div id="preview-wrapper" class="overflow-auto">
|
||||
<div id="preview-container">
|
||||
<!-- Aici este inserat codul HTML al semnăturii -->
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const controls = {
|
||||
prefix: document.getElementById('input-prefix'),
|
||||
name: document.getElementById('input-name'),
|
||||
title: document.getElementById('input-title'),
|
||||
phone: document.getElementById('input-phone'),
|
||||
greenLine: document.getElementById('green-line-width'),
|
||||
gutter: document.getElementById('b-gutter-width'),
|
||||
iconTextSpacing: document.getElementById('icon-text-spacing'),
|
||||
iconVertical: document.getElementById('icon-vertical-pos'),
|
||||
mottoSpacing: document.getElementById('motto-spacing'),
|
||||
sectionSpacing: document.getElementById('section-spacing'),
|
||||
titleSpacing: document.getElementById('title-spacing'),
|
||||
logoSpacing: document.getElementById('logo-spacing'),
|
||||
replyCheckbox: document.getElementById('reply-variant-checkbox'),
|
||||
superReplyCheckbox: document.getElementById('super-reply-variant-checkbox'),
|
||||
useSvgCheckbox: document.getElementById('use-svg-checkbox'),
|
||||
exportBtn: document.getElementById('export-btn'),
|
||||
zoomBtn: document.getElementById('zoom-btn'),
|
||||
colorControls: document.getElementById('color-controls')
|
||||
};
|
||||
|
||||
const values = {
|
||||
greenLine: document.getElementById('green-line-value'),
|
||||
gutter: document.getElementById('b-gutter-value'),
|
||||
iconTextSpacing: document.getElementById('icon-text-spacing-value'),
|
||||
iconVertical: document.getElementById('icon-vertical-value'),
|
||||
mottoSpacing: document.getElementById('motto-spacing-value'),
|
||||
sectionSpacing: document.getElementById('section-spacing-value'),
|
||||
titleSpacing: document.getElementById('title-spacing-value'),
|
||||
logoSpacing: document.getElementById('logo-spacing-value')
|
||||
};
|
||||
|
||||
const previewContainer = document.getElementById('preview-container');
|
||||
const previewWrapper = document.getElementById('preview-wrapper');
|
||||
|
||||
const imageSets = {
|
||||
png: {
|
||||
logo: 'https://beletage.ro/img/Semnatura-Logo.png',
|
||||
greySlash: 'https://beletage.ro/img/Grey-slash.png',
|
||||
greenSlash: 'https://beletage.ro/img/Green-slash.png'
|
||||
},
|
||||
svg: {
|
||||
logo: 'https://beletage.ro/img/Logo-Beletage.svg',
|
||||
greySlash: 'https://beletage.ro/img/Grey-slash.svg',
|
||||
greenSlash: 'https://beletage.ro/img/Green-slash.svg'
|
||||
}
|
||||
};
|
||||
|
||||
const beletageColors = {
|
||||
verde: '#22B5AB',
|
||||
griInchis: '#54504F',
|
||||
griDeschis: '#A7A9AA',
|
||||
negru: '#323232'
|
||||
};
|
||||
|
||||
const colorConfig = {
|
||||
prefix: { label: 'Titulatură', default: beletageColors.griInchis },
|
||||
name: { label: 'Nume', default: beletageColors.griInchis },
|
||||
title: { label: 'Funcție', default: beletageColors.griDeschis },
|
||||
address: { label: 'Adresă', default: beletageColors.griDeschis },
|
||||
phone: { label: 'Telefon', default: beletageColors.griInchis },
|
||||
website: { label: 'Website', default: beletageColors.griInchis },
|
||||
motto: { label: 'Motto', default: beletageColors.verde }
|
||||
};
|
||||
|
||||
let currentColors = {};
|
||||
|
||||
function createColorPickers() {
|
||||
for (const [key, config] of Object.entries(colorConfig)) {
|
||||
currentColors[key] = config.default;
|
||||
const controlRow = document.createElement('div');
|
||||
controlRow.className = 'flex items-center justify-between';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'text-sm font-medium text-gray-700';
|
||||
label.textContent = config.label;
|
||||
controlRow.appendChild(label);
|
||||
const swatchesContainer = document.createElement('div');
|
||||
swatchesContainer.className = 'flex items-center space-x-2';
|
||||
swatchesContainer.dataset.controlKey = key;
|
||||
for (const color of Object.values(beletageColors)) {
|
||||
const swatch = document.createElement('div');
|
||||
swatch.className = 'color-swatch';
|
||||
swatch.style.backgroundColor = color;
|
||||
swatch.dataset.color = color;
|
||||
if (color === config.default) swatch.classList.add('active');
|
||||
swatchesContainer.appendChild(swatch);
|
||||
}
|
||||
controlRow.appendChild(swatchesContainer);
|
||||
controls.colorControls.appendChild(controlRow);
|
||||
}
|
||||
|
||||
controls.colorControls.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('color-swatch')) {
|
||||
const key = e.target.parentElement.dataset.controlKey;
|
||||
currentColors[key] = e.target.dataset.color;
|
||||
e.target.parentElement.querySelectorAll('.color-swatch').forEach(s => s.classList.remove('active'));
|
||||
e.target.classList.add('active');
|
||||
updatePreview();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function generateSignatureHTML(data) {
|
||||
const {
|
||||
prefix, name, title, phone, phoneLink, greenLineWidth, gutterWidth,
|
||||
iconTextSpacing, iconVerticalOffset, mottoSpacing, sectionSpacing, titleSpacing, logoSpacing,
|
||||
isReply, isSuperReply, colors, images
|
||||
} = data;
|
||||
|
||||
const hideTitle = isReply || isSuperReply ? 'mso-hide:all;display:none!important;max-height:0;overflow:hidden;font-size:0;line-height:0;' : '';
|
||||
const hideLogoAddress = isReply || isSuperReply ? 'mso-hide:all;display:none!important;max-height:0;overflow:hidden;font-size:0;line-height:0;' : '';
|
||||
const hideBottom = isSuperReply ? 'mso-hide:all;display:none!important;max-height:0;overflow:hidden;font-size:0;line-height:0;' : '';
|
||||
const hidePhoneIcon = isSuperReply ? 'mso-hide:all;display:none!important;max-height:0;overflow:hidden;font-size:0;line-height:0;' : '';
|
||||
|
||||
const spacerWidth = Math.max(0, iconTextSpacing);
|
||||
const textPaddingLeft = Math.max(0, -iconTextSpacing);
|
||||
|
||||
const prefixHTML = prefix ? `<span style="font-size:13px; color:${colors.prefix};">${prefix} </span>` : '';
|
||||
const logoWidth = controls.useSvgCheckbox.checked ? 162 : 162;
|
||||
const logoHeight = controls.useSvgCheckbox.checked ? 24 : 24;
|
||||
|
||||
return `
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540" style="font-family: Arial, Helvetica, sans-serif; color:#333333; font-size:14px; line-height:18px;">
|
||||
<tbody>
|
||||
<tr><td style="padding:0 0 ${titleSpacing}px 0;">${prefixHTML}<span style="font-size:15px; color:${colors.name}; font-weight:700;">${name}</span></td></tr>
|
||||
<tr style="${hideTitle}"><td style="padding:0 0 8px 0;"><span style="font-size:12px; color:${colors.title};">${title}</span></td></tr>
|
||||
<tr style="${hideBottom}">
|
||||
<td style="padding:0; font-size:0; line-height:0;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540">
|
||||
<tr>
|
||||
<td width="${greenLineWidth}" height="2" bgcolor="${beletageColors.verde}" style="font-size:0; line-height:0; height:2px;"></td>
|
||||
<td width="${540 - greenLineWidth}" height="2" style="font-size:0; line-height:0; height:2px;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="${hideLogoAddress}"><td style="padding:${logoSpacing}px 0 ${parseInt(logoSpacing, 10) + 2}px 0;">
|
||||
<a href="https://www.beletage.ro" style="text-decoration:none; border:0;">
|
||||
<img src="${images.logo}" alt="Beletage" style="display:block; border:0; height:${logoHeight}px; width:${logoWidth}px;" height="${logoHeight}" width="${logoWidth}">
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr>
|
||||
<td style="padding-top:${hideLogoAddress ? '0' : sectionSpacing}px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540" style="font-size:13px; line-height:18px;">
|
||||
<tbody>
|
||||
<tr style="${hideLogoAddress}">
|
||||
<td width="${gutterWidth}" style="width:${gutterWidth}px; font-size:0; line-height:0;"></td>
|
||||
<td width="11" style="width:11px; vertical-align:top; padding-top:${4 + iconVerticalOffset}px;">
|
||||
<img src="${images.greySlash}" alt="" width="11" height="11" style="display: block; border:0;">
|
||||
</td>
|
||||
<td width="${spacerWidth}" style="width:${spacerWidth}px; font-size:0; line-height:0;"></td>
|
||||
<td style="vertical-align:top; padding:0 0 0 ${textPaddingLeft}px;">
|
||||
<a href="https://maps.google.com/?q=str.%20Unirii%203%2C%20ap.%2026%2C%20Cluj-Napoca%20400417%2C%20Rom%C3%A2nia" style="color:${colors.address}; text-decoration:none;"><span style="color:${colors.address}; text-decoration:none;">str. Unirii, nr. 3, ap. 26<br>Cluj-Napoca, Cluj 400417<br>România</span></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="${gutterWidth}" style="width:${gutterWidth}px; font-size:0; line-height:0;"></td>
|
||||
<td width="11" style="width:11px; vertical-align:top; padding-top:${12 + iconVerticalOffset}px; ${hidePhoneIcon}">
|
||||
<img src="${images.greenSlash}" alt="" width="11" height="7" style="display: block; border:0;">
|
||||
</td>
|
||||
<td width="${isSuperReply ? 0 : spacerWidth}" style="width:${isSuperReply ? 0 : spacerWidth}px; font-size:0; line-height:0;"></td>
|
||||
<td style="vertical-align:top; padding:8px 0 0 ${isSuperReply ? 0 : textPaddingLeft}px;">
|
||||
<a href="${phoneLink}" style="color:${colors.phone}; text-decoration:none;"><span style="color:${colors.phone}; text-decoration:none;">${phone}</span></a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="${hideBottom}"><td style="padding:${sectionSpacing}px 0 ${mottoSpacing}px 0;"><a href="https://www.beletage.ro" style="color:${colors.website}; text-decoration:none;"><span style="color:${colors.website}; text-decoration:none;">www.beletage.ro</span></a></td></tr>
|
||||
<tr style="${hideBottom}">
|
||||
<td style="padding:0; font-size:0; line-height:0;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540">
|
||||
<tr>
|
||||
<td width="${greenLineWidth}" height="1" bgcolor="${beletageColors.verde}" style="font-size:0; line-height:0; height:1px;"></td>
|
||||
<td width="${540 - greenLineWidth}" height="1" style="font-size:0; line-height:0; height:1px;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="${hideBottom}"><td style="padding:${mottoSpacing}px 0 0 0;"><span style="font-size:12px; color:${colors.motto}; font-style:italic;">we make complex simple</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
const phoneRaw = controls.phone.value.replace(/\s/g, '');
|
||||
let formattedPhone = controls.phone.value;
|
||||
let phoneLink = `tel:${phoneRaw}`;
|
||||
|
||||
if (phoneRaw.length === 10 && phoneRaw.startsWith('07')) {
|
||||
formattedPhone = `+40 ${phoneRaw.substring(1, 4)} ${phoneRaw.substring(4, 7)} ${phoneRaw.substring(7, 10)}`;
|
||||
phoneLink = `tel:+40${phoneRaw.substring(1)}`;
|
||||
}
|
||||
|
||||
if (controls.superReplyCheckbox.checked) {
|
||||
controls.replyCheckbox.checked = true;
|
||||
controls.replyCheckbox.disabled = true;
|
||||
} else {
|
||||
controls.replyCheckbox.disabled = false;
|
||||
}
|
||||
|
||||
const data = {
|
||||
prefix: controls.prefix.value,
|
||||
name: controls.name.value,
|
||||
title: controls.title.value,
|
||||
phone: formattedPhone,
|
||||
phoneLink: phoneLink,
|
||||
greenLineWidth: controls.greenLine.value,
|
||||
gutterWidth: controls.gutter.value,
|
||||
iconTextSpacing: controls.iconTextSpacing.value,
|
||||
iconVerticalOffset: parseInt(controls.iconVertical.value, 10),
|
||||
mottoSpacing: controls.mottoSpacing.value,
|
||||
sectionSpacing: controls.sectionSpacing.value,
|
||||
titleSpacing: controls.titleSpacing.value,
|
||||
logoSpacing: controls.logoSpacing.value,
|
||||
isReply: controls.replyCheckbox.checked,
|
||||
isSuperReply: controls.superReplyCheckbox.checked,
|
||||
colors: { ...currentColors },
|
||||
images: controls.useSvgCheckbox.checked ? imageSets.svg : imageSets.png
|
||||
};
|
||||
|
||||
values.greenLine.textContent = data.greenLineWidth;
|
||||
values.gutter.textContent = data.gutterWidth;
|
||||
values.iconTextSpacing.textContent = data.iconTextSpacing;
|
||||
values.iconVertical.textContent = data.iconVerticalOffset;
|
||||
values.mottoSpacing.textContent = data.mottoSpacing;
|
||||
values.sectionSpacing.textContent = data.sectionSpacing;
|
||||
values.titleSpacing.textContent = data.titleSpacing;
|
||||
values.logoSpacing.textContent = data.logoSpacing;
|
||||
|
||||
previewContainer.innerHTML = generateSignatureHTML(data);
|
||||
}
|
||||
|
||||
// --- Inițializare ---
|
||||
createColorPickers();
|
||||
|
||||
Object.values(controls).forEach(control => {
|
||||
if (control.id !== 'export-btn' && control.id !== 'zoom-btn') {
|
||||
control.addEventListener('input', updatePreview);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('.collapsible-trigger').forEach(trigger => {
|
||||
trigger.addEventListener('click', () => {
|
||||
const content = trigger.nextElementSibling;
|
||||
trigger.classList.toggle('open');
|
||||
content.classList.toggle('open');
|
||||
});
|
||||
});
|
||||
|
||||
controls.zoomBtn.addEventListener('click', () => {
|
||||
const isZoomed = previewWrapper.style.transform === 'scale(2)';
|
||||
if (isZoomed) {
|
||||
previewWrapper.style.transform = 'scale(1)';
|
||||
controls.zoomBtn.textContent = 'Zoom 200%';
|
||||
} else {
|
||||
previewWrapper.style.transform = 'scale(2)';
|
||||
controls.zoomBtn.textContent = 'Zoom 100%';
|
||||
}
|
||||
});
|
||||
|
||||
controls.exportBtn.addEventListener('click', () => {
|
||||
const finalHTML = previewContainer.innerHTML;
|
||||
const blob = new Blob([finalHTML], { type: 'text/html' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'semnatura-beletage.html';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
});
|
||||
|
||||
updatePreview();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,148 +0,0 @@
|
||||
Pauza de masa
|
||||
Timp personal
|
||||
|
||||
Concediu
|
||||
Compensare overtime
|
||||
|
||||
Beletage
|
||||
Ofertare
|
||||
Configurari
|
||||
Organizare initiala
|
||||
Pregatire Portofoliu
|
||||
Website
|
||||
Documentare
|
||||
Design grafic
|
||||
Design interior
|
||||
Design exterior
|
||||
|
||||
Releveu
|
||||
Reclama
|
||||
|
||||
000 Farmacie
|
||||
002 Cladire birouri Stratec
|
||||
003 PUZ Bellavista
|
||||
007 Design Apartament Teodora
|
||||
010 Casa Doinei
|
||||
016 Duplex Eremia
|
||||
024 Bloc Petofi
|
||||
028 PUZ Borhanci-Sopor
|
||||
033 Mansardare Branului
|
||||
039 Cabinete Stoma Scala
|
||||
041 Imobil mixt Progresului
|
||||
045 Casa Andrei Muresanu
|
||||
052 PUZ Carpenului
|
||||
059 PUZ Nordului
|
||||
064 Casa Salicea
|
||||
066 Terasa Gherase
|
||||
070 Bloc Fanatelor
|
||||
073 Case Frumoasa
|
||||
074 PUG Cosbuc
|
||||
076 Casa Copernicus
|
||||
077 PUZ Schimbare destinatie Brancusi
|
||||
078 Service auto Linistei
|
||||
079 Amenajare drum Servitute Eremia
|
||||
080 Bloc Tribunul
|
||||
081 Extindere casa Gherase
|
||||
083 Modificari casa Zsigmund 18
|
||||
084 Mansardare Petofi 21
|
||||
085 Container CT Spital Tabacarilor
|
||||
086 Imprejmuire casa sat Gheorgheni
|
||||
087 Duplex Oasului fn
|
||||
089 PUZ A-Liu Sopor
|
||||
090 VR MedEvents
|
||||
091 Reclama Caparol
|
||||
092 Imobil birouri 13 Septembrie
|
||||
093 Casa Salistea Noua
|
||||
094 PUD Casa Rediu
|
||||
095 Duplex Vanatorului
|
||||
096 Design apartament Sopor
|
||||
097 Cabana Gilau
|
||||
101 PUZ Gilau
|
||||
102 PUZ Ghimbav
|
||||
103 Piscine Lunca Noua
|
||||
104 PUZ REGHIN
|
||||
105 CUT&Crust
|
||||
106 PUZ Mihai Romanu Nord
|
||||
108 Reabilitare Bloc Beiusului
|
||||
109 Case Samboleni
|
||||
110 Penny Crasna
|
||||
111 Anexa Piscina Borhanci
|
||||
112 PUZ Blocuri Bistrita
|
||||
113 PUZ VARATEC-FIRIZA
|
||||
114 PUG Husi
|
||||
115 PUG Josenii Bargaului
|
||||
116 PUG Monor
|
||||
117 Schimbare Destinatie Mihai Viteazu 2
|
||||
120 Anexa Brasov
|
||||
121 Imprejurare imobil Mesterul Manole 9
|
||||
122 Fastfood Bashar
|
||||
123 PUD Rediu 2
|
||||
127 Casa Socaciu Ciurila
|
||||
128 Schimbare de destinatie Danubius
|
||||
129 (re) Casa Sarca-Sorescu
|
||||
130 Casa Suta-Wonderland
|
||||
131 PUD Oasului Hufi
|
||||
132 Reabilitare Camin Cultural Baciu
|
||||
133 PUG Feldru
|
||||
134 DALI Blocuri Murfatlar
|
||||
135 Case de vacanta Dianei
|
||||
136 PUG BROSTENI
|
||||
139 Casa Turda
|
||||
140 Releveu Bistrita (Morariu)
|
||||
141 PUZ Janovic Jeno
|
||||
142 Penny Borhanci
|
||||
143 Pavilion Politie Radauti
|
||||
149 Duplex Sorescu 31-33
|
||||
150 DALI SF Scoala Baciu
|
||||
151 Casa Alexandru Bohatiel 17
|
||||
152 PUZ Penny Tautii Magheraus
|
||||
153 PUG Banita
|
||||
155 PT Scoala Floresti
|
||||
156 Case Sorescu
|
||||
157 Gradi-Cresa Baciu
|
||||
158 Duplex Sorescu 21-23
|
||||
159 Amenajare Spatiu Grenke PBC
|
||||
160 Etajare Primaria Baciu
|
||||
161 Extindere Ap Baciu
|
||||
164 SD salon Aurel Vlaicu
|
||||
165 Reclama Marasti
|
||||
166 Catei Apahida
|
||||
167 Apartament Mircea Zaciu 13-15
|
||||
169 Casa PETRILA 37
|
||||
170 Cabana Campeni AB
|
||||
171 Camin Apahida
|
||||
L089 PUZ TUSA-BOJAN
|
||||
172 Design casa Iugoslaviei 18
|
||||
173 Reabilitare spitale Sighetu
|
||||
174 StudX UMFST
|
||||
176 - 2025 - ReAC Ansamblu rezi Bibescu
|
||||
|
||||
|
||||
CU
|
||||
Schita
|
||||
Avize
|
||||
PUD
|
||||
AO
|
||||
PUZ
|
||||
PUG
|
||||
DTAD
|
||||
DTAC
|
||||
PT
|
||||
Detalii de Executie
|
||||
Studii de fundamentare
|
||||
|
||||
Regulament
|
||||
Parte desenata
|
||||
Parte scrisa
|
||||
Consultanta client
|
||||
Macheta
|
||||
Consultanta receptie
|
||||
|
||||
Redactare
|
||||
Depunere
|
||||
Ridicare
|
||||
Verificare proiect
|
||||
|
||||
Vizita santier
|
||||
|
||||
Master MATDR
|
||||
@@ -1,694 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ro">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Beletage – Word XML Data Engine</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!-- JSZip pentru arhivă ZIP -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"
|
||||
integrity="sha512-FGv7V3GpCr3C6wz6Q4z8F1v8y4mZohwPqhwKiPfz0btvAvOE0tfLOgvBcFQncn1C3KW0y5fN9c7v1sQW8vGfMQ=="
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
margin: 0;
|
||||
padding: 1.5rem;
|
||||
background: #020617;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.7rem;
|
||||
margin-bottom: .25rem;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: .9rem;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.card {
|
||||
background: #020617;
|
||||
border-radius: 1rem;
|
||||
padding: 1.1rem 1.3rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid #1e293b;
|
||||
box-shadow: 0 15px 35px rgba(0,0,0,.45);
|
||||
}
|
||||
label {
|
||||
font-size: .8rem;
|
||||
color: #9ca3af;
|
||||
display: block;
|
||||
margin-bottom: .2rem;
|
||||
}
|
||||
input, textarea, select {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: .5rem .6rem;
|
||||
border-radius: .5rem;
|
||||
border: 1px solid #334155;
|
||||
background: #020617;
|
||||
color: #e5e7eb;
|
||||
font-family: inherit;
|
||||
font-size: .9rem;
|
||||
outline: none;
|
||||
}
|
||||
input:focus, textarea:focus, select:focus {
|
||||
border-color: #38bdf8;
|
||||
box-shadow: 0 0 0 1px #38bdf8;
|
||||
}
|
||||
textarea { min-height: 140px; resize: vertical; }
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
.col-3 { flex: 1 1 220px; }
|
||||
.col-6 { flex: 1 1 320px; }
|
||||
.col-9 { flex: 3 1 420px; }
|
||||
|
||||
button {
|
||||
padding: .55rem 1.1rem;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #38bdf8, #6366f1);
|
||||
color: #fff;
|
||||
font-size: .9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 12px 25px rgba(37,99,235,.4);
|
||||
}
|
||||
button:hover { filter: brightness(1.05); transform: translateY(-1px); }
|
||||
button:active { transform: translateY(0); box-shadow: 0 8px 18px rgba(37,99,235,.6); }
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
border: 1px solid #4b5563;
|
||||
box-shadow: none;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: #020617;
|
||||
box-shadow: 0 8px 20px rgba(0,0,0,.6);
|
||||
}
|
||||
.btn-small {
|
||||
font-size: .8rem;
|
||||
padding: .35rem .8rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .4rem;
|
||||
font-size: .8rem;
|
||||
color: #cbd5f5;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.toggle input { width: auto; }
|
||||
|
||||
.pill-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .4rem;
|
||||
margin-bottom: .4rem;
|
||||
}
|
||||
.pill {
|
||||
padding: .25rem .7rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #334155;
|
||||
font-size: .8rem;
|
||||
cursor: pointer;
|
||||
background: #020617;
|
||||
color: #e5e7eb;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .35rem;
|
||||
}
|
||||
.pill.active {
|
||||
background: linear-gradient(135deg, #38bdf8, #6366f1);
|
||||
border-color: transparent;
|
||||
color: #0f172a;
|
||||
}
|
||||
.pill span.remove {
|
||||
font-size: .8rem;
|
||||
opacity: .7;
|
||||
}
|
||||
.pill span.remove:hover { opacity: 1; }
|
||||
|
||||
.small {
|
||||
font-size: .8rem;
|
||||
color: #9ca3af;
|
||||
margin-top: .25rem;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #020617;
|
||||
border-radius: .75rem;
|
||||
padding: .7rem .8rem;
|
||||
border: 1px solid #1f2937;
|
||||
overflow: auto;
|
||||
font-size: .8rem;
|
||||
max-height: 340px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: .15rem .45rem;
|
||||
border-radius: 999px;
|
||||
font-size: .7rem;
|
||||
background: rgba(148,163,184,.18);
|
||||
margin-right: .4rem;
|
||||
margin-bottom: .25rem;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
body { padding: 1rem; }
|
||||
.card { padding: 1rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Beletage – Word XML Data Engine</h1>
|
||||
<p class="subtitle">
|
||||
Generator de <strong>Custom XML Parts</strong> pentru Word, pe categorii (Beneficiar, Proiect, Suprafete, Meta etc.),
|
||||
cu mod <em>Simple</em> / <em>Advanced</em> și câmpuri derivate (Short, Upper, Initials) + POT/CUT pregătite.
|
||||
</p>
|
||||
|
||||
<!-- SETĂRI GLOBALE -->
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<label for="baseNs">Bază Namespace (se completează automat cu /Categorie)</label>
|
||||
<input id="baseNs" type="text" value="http://schemas.beletage.ro/contract">
|
||||
<div class="small">Ex: <code>http://schemas.beletage.ro/contract</code> → pentru categoria „Proiect” devine
|
||||
<code>http://schemas.beletage.ro/contract/Proiect</code>.
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<label>Mod generare câmpuri</label>
|
||||
<div class="pill-row">
|
||||
<div class="pill active" id="modeSimplePill" onclick="setMode('simple')">Simple</div>
|
||||
<div class="pill" id="modeAdvancedPill" onclick="setMode('advanced')">Advanced</div>
|
||||
</div>
|
||||
<div class="small">
|
||||
<strong>Simple</strong>: doar câmpurile tale.<br>
|
||||
<strong>Advanced</strong>: + Short / Upper / Lower / Initials / First pentru fiecare câmp.
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<label>Opțiuni extra</label>
|
||||
<div class="small" style="margin-top:.25rem;">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="computeMetrics" checked>
|
||||
<span>Adaugă câmpuri POT / CUT în categoria Suprafete</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CATEGORII -->
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="col-3">
|
||||
<label>Categorii de date</label>
|
||||
<div id="categoryPills" class="pill-row"></div>
|
||||
<button class="btn-secondary btn-small" onclick="addCategoryPrompt()">+ Adaugă categorie</button>
|
||||
<div class="small">
|
||||
Exemple de organizare: <code>Beneficiar</code>, <code>Proiect</code>, <code>Suprafete</code>, <code>Meta</code>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-9">
|
||||
<label>Câmpuri pentru categoria selectată</label>
|
||||
<textarea id="fieldsArea"></textarea>
|
||||
<div class="small">
|
||||
Un câmp pe linie. Poți edita lista. Butonul „Reset categorie la preset” reîncarcă valorile default pentru
|
||||
categoria curentă (dacă există).
|
||||
</div>
|
||||
<div style="margin-top:.5rem; display:flex; gap:.5rem; flex-wrap:wrap;">
|
||||
<button class="btn-secondary btn-small" onclick="resetCategoryToPreset()">Reset categorie la preset</button>
|
||||
<button class="btn-secondary btn-small" onclick="clearCategoryFields()">Curăță câmpurile</button>
|
||||
</div>
|
||||
<div class="small" id="nsRootInfo" style="margin-top:.6rem;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GENERARE & DOWNLOAD -->
|
||||
<div class="card">
|
||||
<div style="display:flex; flex-wrap:wrap; gap:.5rem; align-items:center; margin-bottom:.5rem;">
|
||||
<button onclick="generateAll()">Generează XML pentru toate categoriile</button>
|
||||
<button class="btn-secondary" onclick="downloadCurrentXml()">Descarcă XML categorie curentă</button>
|
||||
<button class="btn-secondary" onclick="downloadZipAll()">Descarcă ZIP cu toate XML-urile</button>
|
||||
</div>
|
||||
<div class="small">
|
||||
<span class="badge">Tip</span>
|
||||
În Word, fiecare fișier generat devine un Custom XML Part separat (ex: <code>BeneficiarData.xml</code>,
|
||||
<code>ProiectData.xml</code> etc.), perfect pentru organizarea mapping-urilor.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PREVIEW -->
|
||||
<div class="card">
|
||||
<h3 style="margin-top:0;">Preview XML & XPaths</h3>
|
||||
<div class="small" style="margin-bottom:.4rem;">
|
||||
Selectează o categorie pentru a vedea XML-ul și XPaths-urile aferente.
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<div class="badge">XML categorie curentă</div>
|
||||
<pre id="xmlPreview"></pre>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="badge">XPaths categorie curentă</div>
|
||||
<pre id="xpathPreview"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// --- PRESETURI CATEGORII ---
|
||||
const defaultPresets = {
|
||||
"Beneficiar": [
|
||||
"NumeClient",
|
||||
"Adresa",
|
||||
"CUI",
|
||||
"CNP",
|
||||
"Reprezentant",
|
||||
"Email",
|
||||
"Telefon"
|
||||
],
|
||||
"Proiect": [
|
||||
"TitluProiect",
|
||||
"AdresaImobil",
|
||||
"NrCadastral",
|
||||
"NrCF",
|
||||
"Localitate",
|
||||
"Judet"
|
||||
],
|
||||
"Suprafete": [
|
||||
"SuprafataTeren",
|
||||
"SuprafataConstruitaLaSol",
|
||||
"SuprafataDesfasurata",
|
||||
"SuprafataUtila"
|
||||
],
|
||||
"Meta": [
|
||||
"NrContract",
|
||||
"DataContract",
|
||||
"Responsabil",
|
||||
"VersiuneDocument",
|
||||
"DataGenerarii"
|
||||
]
|
||||
};
|
||||
|
||||
// --- STATE ---
|
||||
let categories = {}; // { Categorie: { fieldsText: "..." } }
|
||||
let currentCategory = "Beneficiar";
|
||||
let mode = "advanced"; // "simple" | "advanced"
|
||||
const xmlParts = {}; // { Categorie: xmlString }
|
||||
const xpathParts = {}; // { Categorie: xpathString }
|
||||
|
||||
// --- UTILITARE ---
|
||||
function sanitizeName(name) {
|
||||
if (!name) return null;
|
||||
let n = name.trim();
|
||||
if (!n) return null;
|
||||
n = n.replace(/\s+/g, "_").replace(/[^A-Za-z0-9_.-]/g, "");
|
||||
if (!/^[A-Za-z_]/.test(n)) n = "_" + n;
|
||||
return n;
|
||||
}
|
||||
|
||||
function initialsFromLabel(label) {
|
||||
if (!label) return "";
|
||||
return label.trim().split(/\s+/).map(s => s.charAt(0).toUpperCase() + ".").join("");
|
||||
}
|
||||
|
||||
function firstToken(label) {
|
||||
if (!label) return "";
|
||||
return label.trim().split(/\s+/)[0] || "";
|
||||
}
|
||||
|
||||
function getBaseNamespace() {
|
||||
const val = document.getElementById("baseNs").value.trim();
|
||||
return val || "http://schemas.beletage.ro/contract";
|
||||
}
|
||||
|
||||
function getCategoryNamespace(cat) {
|
||||
const base = getBaseNamespace();
|
||||
const safeCat = sanitizeName(cat) || cat;
|
||||
return base.replace(/\/+$/,"") + "/" + safeCat;
|
||||
}
|
||||
|
||||
function getCategoryRoot(cat) {
|
||||
const safeCat = sanitizeName(cat) || cat;
|
||||
return safeCat + "Data";
|
||||
}
|
||||
|
||||
// --- MOD SIMPLE/ADVANCED ---
|
||||
function setMode(m) {
|
||||
mode = m === "advanced" ? "advanced" : "simple";
|
||||
document.getElementById("modeSimplePill").classList.toggle("active", mode === "simple");
|
||||
document.getElementById("modeAdvancedPill").classList.toggle("active", mode === "advanced");
|
||||
// regenerăm previw dacă avem ceva
|
||||
generateAll(false);
|
||||
}
|
||||
|
||||
// --- CATEGORII: INIT, UI, STORAGE ---
|
||||
function initCategories() {
|
||||
// încarcă din localStorage, altfel default
|
||||
const saved = window.localStorage.getItem("beletage_xml_categories");
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
categories = parsed.categories || {};
|
||||
currentCategory = parsed.currentCategory || "Beneficiar";
|
||||
} catch(e) {
|
||||
Object.keys(defaultPresets).forEach(cat => {
|
||||
categories[cat] = { fieldsText: defaultPresets[cat].join("\n") };
|
||||
});
|
||||
currentCategory = "Beneficiar";
|
||||
}
|
||||
} else {
|
||||
Object.keys(defaultPresets).forEach(cat => {
|
||||
categories[cat] = { fieldsText: defaultPresets[cat].join("\n") };
|
||||
});
|
||||
currentCategory = "Beneficiar";
|
||||
}
|
||||
|
||||
renderCategoryPills();
|
||||
loadCategoryToUI(currentCategory);
|
||||
}
|
||||
|
||||
function persistCategories() {
|
||||
try {
|
||||
window.localStorage.setItem("beletage_xml_categories", JSON.stringify({
|
||||
categories,
|
||||
currentCategory
|
||||
}));
|
||||
} catch(e){}
|
||||
}
|
||||
|
||||
function renderCategoryPills() {
|
||||
const container = document.getElementById("categoryPills");
|
||||
container.innerHTML = "";
|
||||
Object.keys(categories).forEach(cat => {
|
||||
const pill = document.createElement("div");
|
||||
pill.className = "pill" + (cat === currentCategory ? " active" : "");
|
||||
pill.onclick = () => switchCategory(cat);
|
||||
pill.textContent = cat;
|
||||
|
||||
// nu permitem ștergerea preset-urilor de bază direct (doar la custom)
|
||||
if (!defaultPresets[cat]) {
|
||||
const remove = document.createElement("span");
|
||||
remove.className = "remove";
|
||||
remove.textContent = "×";
|
||||
remove.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
deleteCategory(cat);
|
||||
};
|
||||
pill.appendChild(remove);
|
||||
}
|
||||
container.appendChild(pill);
|
||||
});
|
||||
}
|
||||
|
||||
function switchCategory(cat) {
|
||||
saveCurrentCategoryFields();
|
||||
currentCategory = cat;
|
||||
renderCategoryPills();
|
||||
loadCategoryToUI(cat);
|
||||
updateNsRootInfo();
|
||||
showPreview(cat);
|
||||
persistCategories();
|
||||
}
|
||||
|
||||
function loadCategoryToUI(cat) {
|
||||
const area = document.getElementById("fieldsArea");
|
||||
area.value = categories[cat]?.fieldsText || "";
|
||||
updateNsRootInfo();
|
||||
}
|
||||
|
||||
function saveCurrentCategoryFields() {
|
||||
const area = document.getElementById("fieldsArea");
|
||||
if (!categories[currentCategory]) {
|
||||
categories[currentCategory] = { fieldsText: "" };
|
||||
}
|
||||
categories[currentCategory].fieldsText = area.value;
|
||||
}
|
||||
|
||||
function deleteCategory(cat) {
|
||||
if (!confirm(`Sigur ștergi categoria "${cat}"?`)) return;
|
||||
delete categories[cat];
|
||||
const keys = Object.keys(categories);
|
||||
currentCategory = keys[0] || "Beneficiar";
|
||||
renderCategoryPills();
|
||||
loadCategoryToUI(currentCategory);
|
||||
updateNsRootInfo();
|
||||
persistCategories();
|
||||
}
|
||||
|
||||
function addCategoryPrompt() {
|
||||
const name = prompt("Nume categorie nouă (ex: Urbanism, Fiscal, Altele):");
|
||||
if (!name) return;
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
if (categories[trimmed]) {
|
||||
alert("Categoria există deja.");
|
||||
return;
|
||||
}
|
||||
categories[trimmed] = { fieldsText: "" };
|
||||
currentCategory = trimmed;
|
||||
renderCategoryPills();
|
||||
loadCategoryToUI(currentCategory);
|
||||
updateNsRootInfo();
|
||||
persistCategories();
|
||||
}
|
||||
|
||||
function resetCategoryToPreset() {
|
||||
if (!defaultPresets[currentCategory]) {
|
||||
alert("Categoria curentă nu are preset definit.");
|
||||
return;
|
||||
}
|
||||
if (!confirm("Resetezi lista de câmpuri la presetul standard pentru această categorie?")) return;
|
||||
categories[currentCategory].fieldsText = defaultPresets[currentCategory].join("\n");
|
||||
loadCategoryToUI(currentCategory);
|
||||
persistCategories();
|
||||
}
|
||||
|
||||
function clearCategoryFields() {
|
||||
categories[currentCategory].fieldsText = "";
|
||||
loadCategoryToUI(currentCategory);
|
||||
persistCategories();
|
||||
}
|
||||
|
||||
function updateNsRootInfo() {
|
||||
const ns = getCategoryNamespace(currentCategory);
|
||||
const root = getCategoryRoot(currentCategory);
|
||||
document.getElementById("nsRootInfo").innerHTML =
|
||||
`<strong>Namespace:</strong> <code>${ns}</code><br>` +
|
||||
`<strong>Root element:</strong> <code><${root}></code>`;
|
||||
}
|
||||
|
||||
// --- GENERARE XML PENTRU O CATEGORIE ---
|
||||
function generateCategory(cat) {
|
||||
const entry = categories[cat];
|
||||
if (!entry) return { xml: "", xpaths: "" };
|
||||
|
||||
const raw = (entry.fieldsText || "").split(/\r?\n/)
|
||||
.map(l => l.trim())
|
||||
.filter(l => l.length > 0);
|
||||
|
||||
if (raw.length === 0) {
|
||||
return { xml: "", xpaths: "" };
|
||||
}
|
||||
|
||||
const ns = getCategoryNamespace(cat);
|
||||
const root = getCategoryRoot(cat);
|
||||
const computeMetrics = document.getElementById("computeMetrics").checked;
|
||||
|
||||
const usedNames = new Set();
|
||||
const fields = []; // { label, baseName, variants: [] }
|
||||
|
||||
for (const label of raw) {
|
||||
const base = sanitizeName(label);
|
||||
if (!base) continue;
|
||||
|
||||
let baseName = base;
|
||||
let idx = 2;
|
||||
while (usedNames.has(baseName)) {
|
||||
baseName = base + "_" + idx;
|
||||
idx++;
|
||||
}
|
||||
usedNames.add(baseName);
|
||||
|
||||
const variants = [baseName];
|
||||
if (mode === "advanced") {
|
||||
const advCandidates = [
|
||||
baseName + "Short",
|
||||
baseName + "Upper",
|
||||
baseName + "Lower",
|
||||
baseName + "Initials",
|
||||
baseName + "First"
|
||||
];
|
||||
for (let v of advCandidates) {
|
||||
let vn = v;
|
||||
let k = 2;
|
||||
while (usedNames.has(vn)) {
|
||||
vn = v + "_" + k;
|
||||
k++;
|
||||
}
|
||||
usedNames.add(vn);
|
||||
variants.push(vn);
|
||||
}
|
||||
}
|
||||
|
||||
fields.push({ label, baseName, variants });
|
||||
}
|
||||
|
||||
// detectăm câmpuri pentru metrici (în special categoria Suprafete)
|
||||
const extraMetricFields = [];
|
||||
if (computeMetrics && cat.toLowerCase().includes("suprafete")) {
|
||||
const hasTeren = fields.some(f => f.baseName.toLowerCase().includes("suprafatateren"));
|
||||
const hasLaSol = fields.some(f => f.baseName.toLowerCase().includes("suprafataconstruitalasol"));
|
||||
const hasDesf = fields.some(f => f.baseName.toLowerCase().includes("suprafatadesfasurata"));
|
||||
|
||||
if (hasTeren && hasLaSol) {
|
||||
if (!usedNames.has("POT")) {
|
||||
usedNames.add("POT");
|
||||
extraMetricFields.push({ label: "Procent Ocupare Teren", baseName: "POT", variants: ["POT"] });
|
||||
}
|
||||
}
|
||||
if (hasTeren && hasDesf) {
|
||||
if (!usedNames.has("CUT")) {
|
||||
usedNames.add("CUT");
|
||||
extraMetricFields.push({ label: "Coeficient Utilizare Teren", baseName: "CUT", variants: ["CUT"] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// generăm XML
|
||||
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
||||
xml += `<${root} xmlns="${ns}">\n`;
|
||||
|
||||
const allFieldEntries = fields.concat(extraMetricFields);
|
||||
|
||||
for (const f of allFieldEntries) {
|
||||
for (const v of f.variants) {
|
||||
xml += ` <${v}></${v}>\n`;
|
||||
}
|
||||
}
|
||||
|
||||
xml += `</${root}>\n`;
|
||||
|
||||
// generăm XPaths
|
||||
let xp = `Categorie: ${cat}\nNamespace: ${ns}\nRoot: /${root}\n\n`;
|
||||
for (const f of fields) {
|
||||
xp += `# ${f.label}\n`;
|
||||
for (const v of f.variants) {
|
||||
xp += `/${root}/${v}\n`;
|
||||
}
|
||||
xp += `\n`;
|
||||
}
|
||||
if (extraMetricFields.length > 0) {
|
||||
xp += `# Metrici auto (POT / CUT)\n`;
|
||||
for (const f of extraMetricFields) {
|
||||
for (const v of f.variants) {
|
||||
xp += `/${root}/${v}\n`;
|
||||
}
|
||||
}
|
||||
xp += `\n`;
|
||||
}
|
||||
|
||||
return { xml, xpaths: xp };
|
||||
}
|
||||
|
||||
// --- GENERARE PENTRU TOATE CATEGORIILE ---
|
||||
function generateAll(showForCurrent = true) {
|
||||
saveCurrentCategoryFields();
|
||||
Object.keys(categories).forEach(cat => {
|
||||
const { xml, xpaths } = generateCategory(cat);
|
||||
xmlParts[cat] = xml;
|
||||
xpathParts[cat] = xpaths;
|
||||
});
|
||||
if (showForCurrent) {
|
||||
showPreview(currentCategory);
|
||||
}
|
||||
persistCategories();
|
||||
}
|
||||
|
||||
// --- PREVIEW ---
|
||||
function showPreview(cat) {
|
||||
document.getElementById("xmlPreview").textContent = xmlParts[cat] || "<!-- Niciun XML generat încă pentru această categorie. -->";
|
||||
document.getElementById("xpathPreview").textContent = xpathParts[cat] || "";
|
||||
}
|
||||
|
||||
// --- DOWNLOAD: XML CATEGORIE ---
|
||||
function downloadCurrentXml() {
|
||||
generateAll(false);
|
||||
const xml = xmlParts[currentCategory];
|
||||
if (!xml) {
|
||||
alert("Nu există XML generat pentru categoria curentă. Apasă întâi „Generează XML pentru toate categoriile”.");
|
||||
return;
|
||||
}
|
||||
const root = getCategoryRoot(currentCategory);
|
||||
const fileName = root + ".xml";
|
||||
const blob = new Blob([xml], { type: "application/xml" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = fileName;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
|
||||
// --- DOWNLOAD: ZIP CU TOATE XML-URILE ---
|
||||
async function downloadZipAll() {
|
||||
generateAll(false);
|
||||
const cats = Object.keys(categories);
|
||||
if (cats.length === 0) {
|
||||
alert("Nu există categorii.");
|
||||
return;
|
||||
}
|
||||
|
||||
const zip = new JSZip();
|
||||
const folder = zip.folder("customXmlParts");
|
||||
|
||||
let hasAny = false;
|
||||
for (const cat of cats) {
|
||||
const xml = xmlParts[cat];
|
||||
if (!xml) continue;
|
||||
hasAny = true;
|
||||
const root = getCategoryRoot(cat);
|
||||
const fileName = root + ".xml";
|
||||
folder.file(fileName, xml);
|
||||
}
|
||||
|
||||
if (!hasAny) {
|
||||
alert("Nu există XML generat încă. Apasă întâi „Generează XML pentru toate categoriile”.");
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await zip.generateAsync({ type: "blob" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(content);
|
||||
a.download = "beletage_custom_xml_parts.zip";
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
|
||||
// --- INIT ---
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
initCategories();
|
||||
updateNsRootInfo();
|
||||
generateAll();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,151 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ro">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Generator XML Word – Versiune Extinsă</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
padding: 1.5rem;
|
||||
background: #0f172a;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.card {
|
||||
background: #020617;
|
||||
border-radius: 1rem;
|
||||
padding: 1.25rem;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
||||
border: 1px solid #1e293b;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
label { font-size: .85rem; color: #94a3b8; }
|
||||
input, textarea {
|
||||
width: 100%; padding: .55rem .7rem;
|
||||
border-radius: .5rem; border: 1px solid #334155;
|
||||
background: #020617; color: #e5e7eb;
|
||||
}
|
||||
textarea { min-height: 120px; }
|
||||
button {
|
||||
padding: .6rem 1.2rem; border-radius: 999px; border: none;
|
||||
background: linear-gradient(135deg,#38bdf8,#6366f1);
|
||||
font-weight: 600; color: white; cursor: pointer;
|
||||
}
|
||||
pre {
|
||||
background: #000; padding: .8rem; border-radius: .7rem;
|
||||
border: 1px solid #1e293b; max-height: 350px; overflow: auto;
|
||||
font-size: .85rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>Generator Word XML – Varianta Extinsă (cu Short / Upper / Lower / Initials)</h1>
|
||||
|
||||
<div class="card">
|
||||
<label>Namespace URI</label>
|
||||
<input id="nsUri" value="http://schemas.beletage.ro/word/contract">
|
||||
|
||||
<label style="margin-top:1rem;">Element rădăcină</label>
|
||||
<input id="rootElement" value="ContractData">
|
||||
|
||||
<label style="margin-top:1rem;">Lista de câmpuri (unul pe linie)</label>
|
||||
<textarea id="fieldList">NumeClient
|
||||
TitluProiect
|
||||
Adresa</textarea>
|
||||
|
||||
<button onclick="generateXML()" style="margin-top:1rem;">Generează XML complet</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Custom XML Part (item1.xml)</h3>
|
||||
<pre id="xmlOutput"></pre>
|
||||
<button onclick="downloadXML()">Descarcă XML</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>XPaths pentru mapping</h3>
|
||||
<pre id="xpathOutput"></pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function sanitize(name) {
|
||||
if (!name) return null;
|
||||
let n = name.trim();
|
||||
if (!n) return null;
|
||||
n = n.replace(/\s+/g,"_").replace(/[^A-Za-z0-9_.-]/g,"");
|
||||
if (!/^[A-Za-z_]/.test(n)) n = "_" + n;
|
||||
return n;
|
||||
}
|
||||
|
||||
function initials(str) {
|
||||
return str.split(/\s+/).map(s => s[0]?.toUpperCase() + ".").join("");
|
||||
}
|
||||
|
||||
function generateXML() {
|
||||
const ns = document.getElementById("nsUri").value.trim();
|
||||
const root = sanitize(document.getElementById("rootElement").value) || "Root";
|
||||
const fieldRaw = document.getElementById("fieldList").value;
|
||||
|
||||
const lines = fieldRaw.split(/\r?\n/)
|
||||
.map(l => l.trim()).filter(l => l.length);
|
||||
|
||||
const fields = [];
|
||||
|
||||
for (let l of lines) {
|
||||
const base = sanitize(l);
|
||||
if (!base) continue;
|
||||
|
||||
fields.push({
|
||||
base,
|
||||
variants: [
|
||||
base, // original
|
||||
base + "Short", // prescurtat
|
||||
base + "Upper", // caps
|
||||
base + "Lower", // lowercase
|
||||
base + "Initials", // inițiale
|
||||
base + "First" // primul cuvânt
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// === GENERĂM XML ===
|
||||
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
||||
xml += `<${root} xmlns="${ns}">\n`;
|
||||
|
||||
for (const f of fields) {
|
||||
for (const v of f.variants) {
|
||||
xml += ` <${v}></${v}>\n`;
|
||||
}
|
||||
}
|
||||
|
||||
xml += `</${root}>`;
|
||||
|
||||
document.getElementById("xmlOutput").textContent = xml;
|
||||
|
||||
// === GENERĂM XPATHS ===
|
||||
let xp = `Namespace: ${ns}\nRoot: /${root}\n\n`;
|
||||
for (const f of fields) {
|
||||
xp += `# ${f.base}\n`;
|
||||
xp += `/${root}/${f.base}\n`;
|
||||
xp += `/${root}/${f.base}Short\n`;
|
||||
xp += `/${root}/${f.base}Upper\n`;
|
||||
xp += `/${root}/${f.base}Lower\n`;
|
||||
xp += `/${root}/${f.base}Initials\n`;
|
||||
xp += `/${root}/${f.base}First\n\n`;
|
||||
}
|
||||
document.getElementById("xpathOutput").textContent = xp;
|
||||
}
|
||||
|
||||
function downloadXML() {
|
||||
const text = document.getElementById("xmlOutput").textContent;
|
||||
const blob = new Blob([text], { type: "application/xml" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = "item1.xml";
|
||||
a.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,330 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ro">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Generator Word XML Custom Part</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
margin: 0;
|
||||
padding: 1.5rem;
|
||||
background: #0f172a;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.container {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.card {
|
||||
background: #020617;
|
||||
border-radius: 1rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
box-shadow: 0 15px 40px rgba(0,0,0,0.35);
|
||||
border: 1px solid #1f2937;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
input, textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #374151;
|
||||
background: #020617;
|
||||
color: #e5e7eb;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
}
|
||||
input:focus, textarea:focus {
|
||||
border-color: #38bdf8;
|
||||
box-shadow: 0 0 0 1px #38bdf8;
|
||||
}
|
||||
textarea {
|
||||
min-height: 140px;
|
||||
resize: vertical;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
.col-6 {
|
||||
flex: 1 1 260px;
|
||||
}
|
||||
button {
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
margin-top: 0.75rem;
|
||||
background: linear-gradient(135deg, #38bdf8, #6366f1);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 10px 25px rgba(37, 99, 235, 0.4);
|
||||
}
|
||||
button:hover {
|
||||
filter: brightness(1.05);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 6px 18px rgba(37,99,235,0.6);
|
||||
}
|
||||
pre {
|
||||
background: #020617;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
overflow: auto;
|
||||
font-size: 0.8rem;
|
||||
border: 1px solid #1f2937;
|
||||
max-height: 360px;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
margin-right: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.pill span {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.small {
|
||||
font-size: 0.8rem;
|
||||
color: #9ca3af;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.btn-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
border: 1px solid #4b5563;
|
||||
box-shadow: none;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: #111827;
|
||||
box-shadow: 0 8px 18px rgba(0,0,0,0.5);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
body {
|
||||
padding: 1rem;
|
||||
}
|
||||
.card {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Generator XML pentru Word Custom XML Part</h1>
|
||||
<p class="subtitle">
|
||||
Introdu câmpurile (unul pe linie) și obții XML pentru <strong>Custom XML Part</strong>, plus XPaths pentru mapping în Word.
|
||||
</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<label for="nsUri">Namespace URI (obligatoriu)</label>
|
||||
<input id="nsUri" type="text"
|
||||
value="http://schemas.beletage.ro/word/data">
|
||||
<div class="small">
|
||||
Exemplu: <code>http://schemas.firma-ta.ro/word/contract</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label for="rootElement">Nume element rădăcină</label>
|
||||
<input id="rootElement" type="text" value="Root">
|
||||
<div class="small">
|
||||
Exemplu: <code>ContractData</code>, <code>ClientInfo</code> etc.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:1rem;">
|
||||
<label for="fieldList">Lista de câmpuri (unul pe linie)</label>
|
||||
<textarea id="fieldList" placeholder="Exemplu:
|
||||
NumeClient
|
||||
Adresa
|
||||
DataContract
|
||||
ValoareTotala"></textarea>
|
||||
<div class="small">
|
||||
Numele va fi curățat automat pentru a fi valid ca nume de element XML
|
||||
(spațiile devin <code>_</code>, caracterele ciudate se elimină).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button type="button" onclick="generateXML()">Generează XML</button>
|
||||
<button type="button" class="btn-secondary" onclick="fillDemo()">Exemplu demo</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="pill"><strong>1</strong><span>Custom XML Part (item1.xml)</span></div>
|
||||
<pre id="xmlOutput"></pre>
|
||||
<div class="btn-row">
|
||||
<button type="button" class="btn-secondary" onclick="copyToClipboard('xmlOutput')">
|
||||
Copiază XML
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" onclick="downloadXML()">
|
||||
Descarcă item1.xml
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="pill"><strong>2</strong><span>XPaths pentru mapping în Word</span></div>
|
||||
<pre id="xpathOutput"></pre>
|
||||
<button type="button" class="btn-secondary" onclick="copyToClipboard('xpathOutput')">
|
||||
Copiază XPaths
|
||||
</button>
|
||||
<p class="small">
|
||||
În Word → <strong>Developer</strong> → <strong>XML Mapping Pane</strong> → alegi Custom XML Part-ul
|
||||
→ pentru fiecare câmp, click dreapta → <em>Insert Content Control</em> → tipul dorit.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function sanitizeXmlName(name) {
|
||||
if (!name) return null;
|
||||
let n = name.trim();
|
||||
if (!n) return null;
|
||||
|
||||
// înlocuim spații cu underscore
|
||||
n = n.replace(/\s+/g, "_");
|
||||
|
||||
// eliminăm caractere invalide pentru nume de element XML
|
||||
n = n.replace(/[^A-Za-z0-9_.-]/g, "");
|
||||
|
||||
// numele XML nu are voie să înceapă cu cifră sau punct sau cratimă
|
||||
if (!/^[A-Za-z_]/.test(n)) {
|
||||
n = "_" + n;
|
||||
}
|
||||
|
||||
return n || null;
|
||||
}
|
||||
|
||||
function generateXML() {
|
||||
const nsUri = document.getElementById("nsUri").value.trim();
|
||||
const root = sanitizeXmlName(document.getElementById("rootElement").value) || "Root";
|
||||
const fieldRaw = document.getElementById("fieldList").value;
|
||||
const xmlOutput = document.getElementById("xmlOutput");
|
||||
const xpathOutput = document.getElementById("xpathOutput");
|
||||
|
||||
if (!nsUri) {
|
||||
alert("Te rog completează Namespace URI.");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = fieldRaw.split(/\r?\n/)
|
||||
.map(l => l.trim())
|
||||
.filter(l => l.length > 0);
|
||||
|
||||
const fields = [];
|
||||
const used = new Set();
|
||||
|
||||
for (let line of lines) {
|
||||
const clean = sanitizeXmlName(line);
|
||||
if (!clean) continue;
|
||||
let finalName = clean;
|
||||
let idx = 2;
|
||||
while (used.has(finalName)) {
|
||||
finalName = clean + "_" + idx;
|
||||
idx++;
|
||||
}
|
||||
used.add(finalName);
|
||||
fields.push({ original: line, xmlName: finalName });
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
xmlOutput.textContent = "<!-- Niciun câmp valid. Completează lista de câmpuri. -->";
|
||||
xpathOutput.textContent = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// Generăm XML-ul pentru Custom XML Part
|
||||
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
||||
xml += `<${root} xmlns="${nsUri}">\n`;
|
||||
for (const f of fields) {
|
||||
xml += ` <${f.xmlName}></${f.xmlName}>\n`;
|
||||
}
|
||||
xml += `</${root}>\n`;
|
||||
|
||||
xmlOutput.textContent = xml;
|
||||
|
||||
// Generăm lista de XPaths
|
||||
let xpaths = `Namespace: ${nsUri}\nRoot: /${root}\n\nCâmpuri:\n`;
|
||||
for (const f of fields) {
|
||||
xpaths += `- ${f.original} => /${root}/${f.xmlName}\n`;
|
||||
}
|
||||
xpathOutput.textContent = xpaths;
|
||||
}
|
||||
|
||||
function copyToClipboard(elementId) {
|
||||
const el = document.getElementById(elementId);
|
||||
if (!el || !el.textContent) return;
|
||||
navigator.clipboard.writeText(el.textContent)
|
||||
.then(() => alert("Copiat în clipboard."))
|
||||
.catch(() => alert("Nu am reușit să copiez în clipboard."));
|
||||
}
|
||||
|
||||
function downloadXML() {
|
||||
const xmlText = document.getElementById("xmlOutput").textContent;
|
||||
if (!xmlText || xmlText.startsWith("<!--")) {
|
||||
alert("Nu există XML valid de descărcat.");
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([xmlText], { type: "application/xml" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "item1.xml";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function fillDemo() {
|
||||
document.getElementById("nsUri").value = "http://schemas.beletage.ro/word/contract";
|
||||
document.getElementById("rootElement").value = "ContractData";
|
||||
document.getElementById("fieldList").value = [
|
||||
"NumeClient",
|
||||
"AdresaClient",
|
||||
"Proiect",
|
||||
"DataContract",
|
||||
"ValoareTotala",
|
||||
"Moneda",
|
||||
"TermenExecutie"
|
||||
].join("\n");
|
||||
generateXML();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,2 @@
|
||||
FROM ghcr.io/maplibre/martin:1.4.0
|
||||
COPY martin.yaml /config/martin.yaml
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
# Martin v1.4 configuration — optimized tile sources for ArchiTools Geoportal
|
||||
# All geometries are EPSG:3844 (Stereo70). Bounds are approximate Romania extent.
|
||||
# Original table data is NEVER modified — views compute simplification on-the-fly.
|
||||
|
||||
postgres:
|
||||
connection_string: ${DATABASE_URL}
|
||||
pool_size: 8
|
||||
default_srid: 3844
|
||||
auto_publish: false
|
||||
tables:
|
||||
# ── UAT boundaries: 4 zoom-dependent simplification levels ──
|
||||
|
||||
gis_uats_z0:
|
||||
schema: public
|
||||
table: gis_uats_z0
|
||||
geometry_column: geom
|
||||
srid: 3844
|
||||
bounds: [20.2, 43.5, 30.0, 48.3]
|
||||
minzoom: 0
|
||||
maxzoom: 5
|
||||
properties:
|
||||
name: text
|
||||
siruta: text
|
||||
|
||||
gis_uats_z5:
|
||||
schema: public
|
||||
table: gis_uats_z5
|
||||
geometry_column: geom
|
||||
srid: 3844
|
||||
bounds: [20.2, 43.5, 30.0, 48.3]
|
||||
minzoom: 5
|
||||
maxzoom: 8
|
||||
properties:
|
||||
name: text
|
||||
siruta: text
|
||||
|
||||
gis_uats_z8:
|
||||
schema: public
|
||||
table: gis_uats_z8
|
||||
geometry_column: geom
|
||||
srid: 3844
|
||||
bounds: [20.2, 43.5, 30.0, 48.3]
|
||||
minzoom: 8
|
||||
maxzoom: 12
|
||||
properties:
|
||||
name: text
|
||||
siruta: text
|
||||
county: text
|
||||
|
||||
gis_uats_z12:
|
||||
schema: public
|
||||
table: gis_uats_z12
|
||||
geometry_column: geom
|
||||
srid: 3844
|
||||
bounds: [20.2, 43.5, 30.0, 48.3]
|
||||
minzoom: 12
|
||||
maxzoom: 16
|
||||
properties:
|
||||
name: text
|
||||
siruta: text
|
||||
county: text
|
||||
|
||||
# ── Administrativ (intravilan, arii speciale) — NO simplification ──
|
||||
|
||||
gis_administrativ:
|
||||
schema: public
|
||||
table: gis_administrativ
|
||||
geometry_column: geom
|
||||
srid: 3844
|
||||
bounds: [20.2, 43.5, 30.0, 48.3]
|
||||
minzoom: 10
|
||||
maxzoom: 16
|
||||
properties:
|
||||
object_id: text
|
||||
siruta: text
|
||||
layer_id: text
|
||||
cadastral_ref: text
|
||||
|
||||
# ── Terenuri (parcels) — NO simplification ──
|
||||
|
||||
gis_terenuri:
|
||||
schema: public
|
||||
table: gis_terenuri
|
||||
geometry_column: geom
|
||||
srid: 3844
|
||||
bounds: [20.2, 43.5, 30.0, 48.3]
|
||||
minzoom: 17
|
||||
maxzoom: 18
|
||||
properties:
|
||||
object_id: text
|
||||
siruta: text
|
||||
cadastral_ref: text
|
||||
area_value: float8
|
||||
layer_id: text
|
||||
|
||||
# ── Terenuri cu status enrichment (ParcelSync Harta tab) ──
|
||||
|
||||
gis_terenuri_status:
|
||||
schema: public
|
||||
table: gis_terenuri_status
|
||||
geometry_column: geom
|
||||
srid: 3844
|
||||
bounds: [20.2, 43.5, 30.0, 48.3]
|
||||
minzoom: 13
|
||||
maxzoom: 18
|
||||
properties:
|
||||
object_id: text
|
||||
siruta: text
|
||||
cadastral_ref: text
|
||||
area_value: float8
|
||||
layer_id: text
|
||||
has_enrichment: int4
|
||||
has_building: int4
|
||||
build_legal: int4
|
||||
|
||||
# ── Cladiri cu status legal (ParcelSync Harta tab) ──
|
||||
|
||||
gis_cladiri_status:
|
||||
schema: public
|
||||
table: gis_cladiri_status
|
||||
geometry_column: geom
|
||||
srid: 3844
|
||||
bounds: [20.2, 43.5, 30.0, 48.3]
|
||||
minzoom: 14
|
||||
maxzoom: 18
|
||||
properties:
|
||||
object_id: text
|
||||
siruta: text
|
||||
cadastral_ref: text
|
||||
area_value: float8
|
||||
layer_id: text
|
||||
build_legal: int4
|
||||
|
||||
# ── Cladiri (buildings) — NO simplification ──
|
||||
|
||||
gis_cladiri:
|
||||
schema: public
|
||||
table: gis_cladiri
|
||||
geometry_column: geom
|
||||
srid: 3844
|
||||
bounds: [20.2, 43.5, 30.0, 48.3]
|
||||
minzoom: 17
|
||||
maxzoom: 18
|
||||
properties:
|
||||
object_id: text
|
||||
siruta: text
|
||||
cadastral_ref: text
|
||||
area_value: float8
|
||||
layer_id: text
|
||||
@@ -2,6 +2,32 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
serverExternalPackages: ['busboy'],
|
||||
experimental: {
|
||||
middlewareClientMaxBodySize: '500mb',
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/(.*)',
|
||||
headers: [
|
||||
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
|
||||
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
async rewrites() {
|
||||
const martinUrl = process.env.MARTIN_URL || 'http://martin:3000';
|
||||
return [
|
||||
{
|
||||
source: '/tiles/:path*',
|
||||
destination: `${martinUrl}/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# nginx tile cache for Martin vector tile server
|
||||
# Proxy-cache layer: 10-100x faster on repeat requests, zero PostGIS load for cached tiles
|
||||
|
||||
proxy_cache_path /var/cache/nginx/tiles
|
||||
levels=1:2
|
||||
keys_zone=tiles:64m
|
||||
max_size=2g
|
||||
inactive=7d
|
||||
use_temp_path=off;
|
||||
|
||||
# Log format with cache status for monitoring (docker logs tile-cache | grep HIT/MISS)
|
||||
log_format tiles '$remote_addr [$time_local] "$request" $status '
|
||||
'cache=$upstream_cache_status size=$body_bytes_sent '
|
||||
'time=$request_time';
|
||||
|
||||
server {
|
||||
access_log /var/log/nginx/access.log tiles;
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Health check
|
||||
location = /health {
|
||||
access_log off;
|
||||
return 200 "ok\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# nginx status (active connections, request counts) — for monitoring
|
||||
location = /status {
|
||||
access_log off;
|
||||
stub_status on;
|
||||
}
|
||||
|
||||
# Martin catalog endpoint (no cache)
|
||||
location = /catalog {
|
||||
proxy_pass http://martin:3000/catalog;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# PMTiles from MinIO — HTTPS proxy for browser access (avoids mixed-content block)
|
||||
# Browser fetches: /pmtiles/overview.pmtiles → MinIO http://10.10.10.166:9002/tiles/overview.pmtiles
|
||||
location /pmtiles/ {
|
||||
proxy_pass http://10.10.10.166:9002/tiles/;
|
||||
proxy_set_header Host 10.10.10.166:9002;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# Range requests — essential for PMTiles (byte-range tile lookups)
|
||||
proxy_set_header Range $http_range;
|
||||
proxy_set_header If-Range $http_if_range;
|
||||
proxy_pass_request_headers on;
|
||||
|
||||
# Browser cache — file changes only on rebuild (~weekly)
|
||||
add_header Cache-Control "public, max-age=3600, stale-while-revalidate=86400" always;
|
||||
|
||||
# CORS — PMTiles loaded from tools.beletage.ro page
|
||||
add_header Access-Control-Allow-Origin "*" always;
|
||||
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Range, If-None-Match, If-Range, Accept-Encoding" always;
|
||||
add_header Access-Control-Expose-Headers "Content-Range, Content-Length, ETag, Accept-Ranges" always;
|
||||
|
||||
# Preflight
|
||||
if ($request_method = OPTIONS) {
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Range, If-Range";
|
||||
add_header Access-Control-Max-Age 86400;
|
||||
add_header Content-Length 0;
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
|
||||
# Tile requests — cache aggressively
|
||||
location / {
|
||||
proxy_pass http://martin:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
# Cache config — tiles change only on sync (weekly), long TTL is safe
|
||||
proxy_cache tiles;
|
||||
proxy_cache_key "$request_uri";
|
||||
proxy_cache_valid 200 7d;
|
||||
proxy_cache_valid 204 1h;
|
||||
proxy_cache_valid 404 1m;
|
||||
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
|
||||
proxy_cache_lock on;
|
||||
proxy_cache_lock_timeout 5s;
|
||||
|
||||
# Browser caching — tiles are immutable between syncs
|
||||
add_header Cache-Control "public, max-age=86400, stale-while-revalidate=604800" always;
|
||||
|
||||
# Pass cache status header (useful for debugging)
|
||||
add_header X-Cache-Status $upstream_cache_status always;
|
||||
|
||||
# CORS headers for tile requests
|
||||
add_header Access-Control-Allow-Origin "*" always;
|
||||
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Range, If-None-Match, Accept-Encoding" always;
|
||||
add_header Access-Control-Expose-Headers "Content-Range, Content-Length, ETag, X-Cache-Status" always;
|
||||
|
||||
# Handle preflight
|
||||
if ($request_method = OPTIONS) {
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS";
|
||||
add_header Access-Control-Max-Age 86400;
|
||||
add_header Content-Length 0;
|
||||
return 204;
|
||||
}
|
||||
|
||||
# Let Martin gzip natively — pass compressed response through to client and cache
|
||||
gzip off;
|
||||
|
||||
# Timeouts (Martin can be slow on low-zoom tiles)
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 30s;
|
||||
}
|
||||
}
|
||||
Generated
+298
-22
@@ -11,16 +11,20 @@
|
||||
"@prisma/client": "^6.19.2",
|
||||
"axios": "^1.13.6",
|
||||
"axios-cookiejar-support": "^6.0.5",
|
||||
"busboy": "^1.6.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"docx": "^9.6.0",
|
||||
"form-data": "^4.0.5",
|
||||
"jspdf": "^4.2.0",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.564.0",
|
||||
"maplibre-gl": "^5.21.0",
|
||||
"minio": "^8.0.6",
|
||||
"next": "16.1.6",
|
||||
"next-auth": "^4.24.13",
|
||||
"next-themes": "^0.4.6",
|
||||
"pmtiles": "^4.4.0",
|
||||
"proj4": "^2.20.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"radix-ui": "^1.4.3",
|
||||
@@ -34,6 +38,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/busboy": "^1.5.4",
|
||||
"@types/jszip": "^3.4.0",
|
||||
"@types/node": "^20",
|
||||
"@types/proj4": "^2.5.6",
|
||||
@@ -117,7 +122,6 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -686,7 +690,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -1636,6 +1639,111 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
|
||||
"integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/point-geometry": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
||||
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@mapbox/tiny-sdf": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz",
|
||||
"integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/unitbezier": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
|
||||
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/vector-tile": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz",
|
||||
"integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "~1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^4.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/whoots-js": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
|
||||
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/geojson-vt": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.0.4.tgz",
|
||||
"integrity": "sha512-HYv3POhMRCdhP3UPPATM/hfcy6/WuVIf5FKboH8u/ZuFMTnAIcSVlq5nfOqroLokd925w2QtE7YwquFOIacwVQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"kdbush": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||
"version": "24.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.7.0.tgz",
|
||||
"integrity": "sha512-Ed7rcKYU5iELfablg9Mj+TVCsXsPBgdMyXPRAxb2v7oWg9YJnpQdZ5msDs1LESu/mtXy3Z48Vdppv2t/x5kAhw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
|
||||
"@mapbox/unitbezier": "^0.0.1",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"minimist": "^1.2.8",
|
||||
"quickselect": "^3.0.0",
|
||||
"rw": "^1.3.3",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"gl-style-format": "dist/gl-style-format.mjs",
|
||||
"gl-style-migrate": "dist/gl-style-migrate.mjs",
|
||||
"gl-style-validate": "dist/gl-style-validate.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/mlt": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.8.tgz",
|
||||
"integrity": "sha512-8vtfYGidr1rNkv5IwIoU2lfe3Oy+Wa8HluzQYcQi9cveU9K3pweAal/poQj4GJ0K/EW4bTQp2wVAs09g2yDRZg==",
|
||||
"license": "(MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/vt-pbf": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.0.tgz",
|
||||
"integrity": "sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@mapbox/vector-tile": "^2.0.4",
|
||||
"@maplibre/geojson-vt": "^5.0.4",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"@types/supercluster": "^7.1.3",
|
||||
"pbf": "^4.0.1",
|
||||
"supercluster": "^8.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/vt-pbf/node_modules/@maplibre/geojson-vt": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz",
|
||||
"integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.26.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
|
||||
@@ -1882,7 +1990,6 @@
|
||||
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
@@ -3993,6 +4100,16 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/busboy": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/busboy/-/busboy-1.5.4.tgz",
|
||||
"integrity": "sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -4000,6 +4117,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -4070,7 +4193,6 @@
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -4081,7 +4203,6 @@
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -4093,6 +4214,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/supercluster": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
|
||||
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/tough-cookie": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
|
||||
@@ -4166,7 +4296,6 @@
|
||||
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.56.0",
|
||||
"@typescript-eslint/types": "8.56.0",
|
||||
@@ -4700,7 +4829,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -5085,7 +5213,6 @@
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
@@ -5251,7 +5378,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -5291,6 +5417,17 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -6257,6 +6394,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/earcut": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
|
||||
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/eciesjs": {
|
||||
"version": "0.4.17",
|
||||
"resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.17.tgz",
|
||||
@@ -6570,7 +6713,6 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -6756,7 +6898,6 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@@ -7076,7 +7217,6 @@
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -7758,6 +7898,12 @@
|
||||
"giget": "dist/cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/gl-matrix": {
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
|
||||
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
@@ -7960,7 +8106,6 @@
|
||||
"integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -8902,6 +9047,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-stringify-pretty-compact": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
||||
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
@@ -8973,6 +9124,12 @@
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/kdbush": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
|
||||
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -9418,6 +9575,40 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/maplibre-gl": {
|
||||
"version": "5.21.0",
|
||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.21.0.tgz",
|
||||
"integrity": "sha512-n0v4J/Ge0EG8ix/z3TY3ragtJYMqzbtSnj1riOC0OwQbzwp0lUF2maS1ve1z8HhitQCKtZZiZJhb8to36aMMfQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@mapbox/tiny-sdf": "^2.0.7",
|
||||
"@mapbox/unitbezier": "^0.0.1",
|
||||
"@mapbox/vector-tile": "^2.0.4",
|
||||
"@mapbox/whoots-js": "^3.1.0",
|
||||
"@maplibre/geojson-vt": "^6.0.4",
|
||||
"@maplibre/maplibre-gl-style-spec": "^24.7.0",
|
||||
"@maplibre/mlt": "^1.1.8",
|
||||
"@maplibre/vt-pbf": "^4.3.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"earcut": "^3.0.2",
|
||||
"gl-matrix": "^3.4.4",
|
||||
"kdbush": "^4.0.2",
|
||||
"murmurhash-js": "^1.0.0",
|
||||
"pbf": "^4.0.1",
|
||||
"potpack": "^2.1.0",
|
||||
"quickselect": "^3.0.0",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.14.0",
|
||||
"npm": ">=8.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -9560,7 +9751,6 @@
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
@@ -9687,6 +9877,12 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/murmurhash-js": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
|
||||
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mute-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz",
|
||||
@@ -9943,6 +10139,17 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "7.0.13",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
|
||||
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
|
||||
"license": "MIT-0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/npm-run-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
|
||||
@@ -10509,6 +10716,18 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pbf": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
|
||||
"integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
},
|
||||
"node_modules/perfect-debounce": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||
@@ -10564,6 +10783,15 @@
|
||||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/pmtiles": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.4.0.tgz",
|
||||
"integrity": "sha512-tCLI1C5134MR54i8izUWhse0QUtO/EC33n9yWp1N5dYLLvyc197U0fkF5gAJhq1TdWO9Tvl+9hgvFvM0fR27Zg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
@@ -10625,6 +10853,12 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/potpack": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
|
||||
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/powershell-utils": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz",
|
||||
@@ -10643,7 +10877,6 @@
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.4.tgz",
|
||||
"integrity": "sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
@@ -10700,7 +10933,6 @@
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@prisma/config": "6.19.2",
|
||||
"@prisma/engines": "6.19.2"
|
||||
@@ -10775,6 +11007,12 @@
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/protocol-buffers-schema": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
|
||||
"integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@@ -11039,6 +11277,12 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quickselect": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
||||
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/radix-ui": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz",
|
||||
@@ -11168,7 +11412,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -11178,7 +11421,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -11430,6 +11672,15 @@
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-protobuf-schema": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"protocol-buffers-schema": "^3.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/restore-cursor": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
|
||||
@@ -11540,6 +11791,12 @@
|
||||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rw": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
|
||||
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/safe-array-concat": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
|
||||
@@ -12122,6 +12379,14 @@
|
||||
"stream-chain": "^2.2.5"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strict-event-emitter": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz",
|
||||
@@ -12390,6 +12655,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/supercluster": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
|
||||
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"kdbush": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
@@ -12605,7 +12879,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -12613,6 +12886,12 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyqueue": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
||||
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.0.23",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz",
|
||||
@@ -12659,7 +12938,6 @@
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
|
||||
"integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tldts": "^7.0.5"
|
||||
},
|
||||
@@ -12867,7 +13145,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -13602,7 +13879,6 @@
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -12,16 +12,20 @@
|
||||
"@prisma/client": "^6.19.2",
|
||||
"axios": "^1.13.6",
|
||||
"axios-cookiejar-support": "^6.0.5",
|
||||
"busboy": "^1.6.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"docx": "^9.6.0",
|
||||
"form-data": "^4.0.5",
|
||||
"jspdf": "^4.2.0",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.564.0",
|
||||
"maplibre-gl": "^5.21.0",
|
||||
"minio": "^8.0.6",
|
||||
"next": "16.1.6",
|
||||
"next-auth": "^4.24.13",
|
||||
"next-themes": "^0.4.6",
|
||||
"pmtiles": "^4.4.0",
|
||||
"proj4": "^2.20.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"radix-ui": "^1.4.3",
|
||||
@@ -35,6 +39,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/busboy": "^1.5.4",
|
||||
"@types/jszip": "^3.4.0",
|
||||
"@types/node": "^20",
|
||||
"@types/proj4": "^2.5.6",
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
-- =============================================================================
|
||||
-- PostGIS native geometry setup for GisUat (UAT boundaries)
|
||||
-- Run once manually: PGPASSWORD='...' psql -h 10.10.10.166 -p 5432 \
|
||||
-- -U architools_user -d architools_db -f prisma/gisuat-postgis-setup.sql
|
||||
--
|
||||
-- Idempotent — safe to re-run.
|
||||
--
|
||||
-- What this does:
|
||||
-- 1. Ensures PostGIS extension
|
||||
-- 2. Adds native geometry column (geom) if missing
|
||||
-- 3. Creates function to convert Esri ring JSON -> PostGIS geometry
|
||||
-- 4. Creates trigger to auto-convert on INSERT/UPDATE
|
||||
-- 5. Backfills existing rows
|
||||
-- 6. Creates GiST spatial index
|
||||
-- 7. Creates Martin/QGIS-friendly view 'gis_uats'
|
||||
--
|
||||
-- After running both SQL scripts (postgis-setup.sql + this file), Martin
|
||||
-- will auto-discover these views (any table/view with a 'geom' geometry column):
|
||||
-- - gis_features (master: all GisFeature rows with geometry)
|
||||
-- - gis_terenuri (parcels from GisFeature)
|
||||
-- - gis_cladiri (buildings from GisFeature)
|
||||
-- - gis_documentatii (expertize/zone/receptii from GisFeature)
|
||||
-- - gis_administrativ (limite UAT/intravilan/arii speciale from GisFeature)
|
||||
-- - gis_uats (UAT boundaries from GisUat) <-- this script
|
||||
--
|
||||
-- All geometries are in EPSG:3844 (Stereo70).
|
||||
-- =============================================================================
|
||||
|
||||
-- 1. Ensure PostGIS extension
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
|
||||
-- 2. Add native geometry column (idempotent)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'GisUat' AND column_name = 'geom'
|
||||
) THEN
|
||||
ALTER TABLE "GisUat" ADD COLUMN geom geometry(Geometry, 3844);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 3. Function: convert Esri ring JSON { rings: number[][][] } -> PostGIS geometry
|
||||
-- Esri rings format: each ring is an array of [x, y] coordinate pairs.
|
||||
-- First ring = exterior, subsequent rings = holes.
|
||||
-- Multiple outer rings (non-holes) would need MultiPolygon, but UAT boundaries
|
||||
-- from eTerra typically have a single polygon with possible holes.
|
||||
--
|
||||
-- Strategy: build WKT POLYGON/MULTIPOLYGON from the rings array, then
|
||||
-- use ST_GeomFromText with SRID 3844.
|
||||
CREATE OR REPLACE FUNCTION gis_uat_esri_to_geom(geom_json jsonb)
|
||||
RETURNS geometry AS $$
|
||||
DECLARE
|
||||
rings jsonb;
|
||||
ring jsonb;
|
||||
coord jsonb;
|
||||
ring_count int;
|
||||
coord_count int;
|
||||
i int;
|
||||
j int;
|
||||
wkt_ring text;
|
||||
wkt text;
|
||||
first_x double precision;
|
||||
first_y double precision;
|
||||
last_x double precision;
|
||||
last_y double precision;
|
||||
BEGIN
|
||||
-- Extract the rings array from the JSON
|
||||
rings := geom_json -> 'rings';
|
||||
|
||||
IF rings IS NULL OR jsonb_array_length(rings) = 0 THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
ring_count := jsonb_array_length(rings);
|
||||
|
||||
-- Build WKT POLYGON with all rings (first = exterior, rest = holes)
|
||||
wkt := 'POLYGON(';
|
||||
|
||||
FOR i IN 0 .. ring_count - 1 LOOP
|
||||
ring := rings -> i;
|
||||
coord_count := jsonb_array_length(ring);
|
||||
|
||||
IF coord_count < 3 THEN
|
||||
CONTINUE; -- skip degenerate rings
|
||||
END IF;
|
||||
|
||||
IF i > 0 THEN
|
||||
wkt := wkt || ', ';
|
||||
END IF;
|
||||
|
||||
wkt_ring := '(';
|
||||
|
||||
FOR j IN 0 .. coord_count - 1 LOOP
|
||||
coord := ring -> j;
|
||||
IF j > 0 THEN
|
||||
wkt_ring := wkt_ring || ', ';
|
||||
END IF;
|
||||
wkt_ring := wkt_ring || (coord ->> 0) || ' ' || (coord ->> 1);
|
||||
|
||||
-- Track first and last coordinates to check ring closure
|
||||
IF j = 0 THEN
|
||||
first_x := (coord ->> 0)::double precision;
|
||||
first_y := (coord ->> 1)::double precision;
|
||||
END IF;
|
||||
IF j = coord_count - 1 THEN
|
||||
last_x := (coord ->> 0)::double precision;
|
||||
last_y := (coord ->> 1)::double precision;
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
-- Close the ring if not already closed
|
||||
IF first_x != last_x OR first_y != last_y THEN
|
||||
wkt_ring := wkt_ring || ', ' || first_x::text || ' ' || first_y::text;
|
||||
END IF;
|
||||
|
||||
wkt_ring := wkt_ring || ')';
|
||||
wkt := wkt || wkt_ring;
|
||||
END LOOP;
|
||||
|
||||
wkt := wkt || ')';
|
||||
|
||||
RETURN ST_GeomFromText(wkt, 3844);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||
|
||||
-- 4. Trigger function: auto-convert Esri JSON -> native PostGIS on INSERT/UPDATE
|
||||
CREATE OR REPLACE FUNCTION gis_uat_sync_geom()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.geometry IS NOT NULL THEN
|
||||
BEGIN
|
||||
NEW.geom := gis_uat_esri_to_geom(NEW.geometry::jsonb);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
-- Invalid geometry JSON -> leave geom NULL rather than fail the write
|
||||
NEW.geom := NULL;
|
||||
END;
|
||||
ELSE
|
||||
NEW.geom := NULL;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 5. Attach trigger (drop + recreate for idempotency)
|
||||
DROP TRIGGER IF EXISTS trg_gis_uat_sync_geom ON "GisUat";
|
||||
CREATE TRIGGER trg_gis_uat_sync_geom
|
||||
BEFORE INSERT OR UPDATE OF geometry ON "GisUat"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION gis_uat_sync_geom();
|
||||
|
||||
-- 6. Backfill: convert existing Esri JSON geometries to native PostGIS
|
||||
UPDATE "GisUat"
|
||||
SET geom = gis_uat_esri_to_geom(geometry::jsonb)
|
||||
WHERE geometry IS NOT NULL AND geom IS NULL;
|
||||
|
||||
-- 7. GiST spatial index for fast spatial queries
|
||||
CREATE INDEX IF NOT EXISTS gis_uat_geom_idx
|
||||
ON "GisUat" USING GIST (geom);
|
||||
|
||||
-- =============================================================================
|
||||
-- 8. Zoom-dependent views for Martin vector tiles
|
||||
-- 4 levels of geometry simplification for progressive loading.
|
||||
-- SAFE: these are read-only views — original geom column is NEVER modified.
|
||||
-- =============================================================================
|
||||
|
||||
-- z0-5: Very coarse overview (2000m tolerance) — country-level outlines
|
||||
CREATE OR REPLACE VIEW gis_uats_z0 AS
|
||||
SELECT siruta, name,
|
||||
ST_SimplifyPreserveTopology(geom, 2000) AS geom
|
||||
FROM "GisUat" WHERE geom IS NOT NULL;
|
||||
|
||||
-- z5-8: Coarse (500m tolerance) — regional overview
|
||||
CREATE OR REPLACE VIEW gis_uats_z5 AS
|
||||
SELECT siruta, name,
|
||||
ST_SimplifyPreserveTopology(geom, 500) AS geom
|
||||
FROM "GisUat" WHERE geom IS NOT NULL;
|
||||
|
||||
-- z8-12: Moderate (50m tolerance) — county/city level
|
||||
CREATE OR REPLACE VIEW gis_uats_z8 AS
|
||||
SELECT siruta, name, county,
|
||||
ST_SimplifyPreserveTopology(geom, 50) AS geom
|
||||
FROM "GisUat" WHERE geom IS NOT NULL;
|
||||
|
||||
-- z12+: Original geometry — full precision, no simplification
|
||||
CREATE OR REPLACE VIEW gis_uats_z12 AS
|
||||
SELECT siruta, name, county, geom
|
||||
FROM "GisUat" WHERE geom IS NOT NULL;
|
||||
|
||||
-- Keep the legacy gis_uats view for QGIS compatibility
|
||||
CREATE OR REPLACE VIEW gis_uats AS
|
||||
SELECT siruta, name, county,
|
||||
ST_SimplifyPreserveTopology(geom, 50) AS geom
|
||||
FROM "GisUat" WHERE geom IS NOT NULL;
|
||||
|
||||
-- =============================================================================
|
||||
-- Done! Martin serves these views as vector tiles:
|
||||
-- - gis_uats_z0 (z0-5, 2000m simplification)
|
||||
-- - gis_uats_z5 (z5-8, 500m)
|
||||
-- - gis_uats_z8 (z8-12, 50m)
|
||||
-- - gis_uats_z12 (z12+, 10m near-original)
|
||||
-- - gis_uats (legacy for QGIS, 50m)
|
||||
-- Original geometry in GisUat.geom is NEVER modified.
|
||||
-- SRID: 3844 (Stereo70)
|
||||
-- =============================================================================
|
||||
@@ -59,6 +59,10 @@ WHERE geometry IS NOT NULL AND geom IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS gis_feature_geom_idx
|
||||
ON "GisFeature" USING GIST (geom);
|
||||
|
||||
-- B-tree index on layerId for view filtering (LIKE 'TERENURI%', 'CLADIRI%')
|
||||
CREATE INDEX IF NOT EXISTS gis_feature_layer_id_idx
|
||||
ON "GisFeature" ("layerId");
|
||||
|
||||
-- =============================================================================
|
||||
-- 7. QGIS-friendly views
|
||||
-- - Clean snake_case column names
|
||||
|
||||
+116
-6
@@ -19,6 +19,36 @@ model KeyValueStore {
|
||||
@@index([namespace])
|
||||
}
|
||||
|
||||
// ─── GIS: Sync Scheduling ──────────────────────────────────────────
|
||||
|
||||
model GisSyncRule {
|
||||
id String @id @default(uuid())
|
||||
siruta String? /// Set = UAT-specific rule
|
||||
county String? /// Set = county-wide default rule
|
||||
frequency String /// "3x-daily"|"daily"|"weekly"|"monthly"|"manual"
|
||||
syncTerenuri Boolean @default(true)
|
||||
syncCladiri Boolean @default(true)
|
||||
syncNoGeom Boolean @default(false)
|
||||
syncEnrich Boolean @default(false)
|
||||
priority Int @default(5) /// 1=highest, 10=lowest
|
||||
enabled Boolean @default(true)
|
||||
allowedHoursStart Int? /// null = no restriction, e.g. 1 for 01:00
|
||||
allowedHoursEnd Int? /// e.g. 5 for 05:00
|
||||
allowedDays String? /// e.g. "1,2,3,4,5" for weekdays, null = all days
|
||||
lastSyncAt DateTime?
|
||||
lastSyncStatus String? /// "done"|"error"
|
||||
lastSyncError String?
|
||||
nextDueAt DateTime?
|
||||
label String? /// Human-readable note
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([siruta, county])
|
||||
@@index([enabled, nextDueAt])
|
||||
@@index([county])
|
||||
@@index([frequency])
|
||||
}
|
||||
|
||||
// ─── GIS: eTerra ParcelSync ────────────────────────────────────────
|
||||
|
||||
model GisFeature {
|
||||
@@ -42,7 +72,7 @@ model GisFeature {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
syncRun GisSyncRun? @relation(fields: [syncRunId], references: [id])
|
||||
syncRun GisSyncRun? @relation(fields: [syncRunId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([layerId, objectId])
|
||||
@@index([siruta])
|
||||
@@ -73,12 +103,92 @@ model GisSyncRun {
|
||||
}
|
||||
|
||||
model GisUat {
|
||||
siruta String @id
|
||||
name String
|
||||
county String?
|
||||
workspacePk Int?
|
||||
updatedAt DateTime @updatedAt
|
||||
siruta String @id
|
||||
name String
|
||||
county String?
|
||||
workspacePk Int?
|
||||
geometry Json? /// EsriGeometry { rings: number[][][] } in EPSG:3844
|
||||
areaValue Float? /// Area in sqm from LIMITE_UAT AREA_VALUE field
|
||||
lastUpdatedDtm String? /// LAST_UPDATED_DTM from eTerra — for incremental sync
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([name])
|
||||
@@index([county])
|
||||
}
|
||||
|
||||
// ─── Registratura: Atomic Sequences + Audit ────────────────────────
|
||||
|
||||
model RegistrySequence {
|
||||
id String @id @default(uuid())
|
||||
company String // B, U, S, G (single-letter prefix)
|
||||
year Int
|
||||
type String // SEQ (shared across directions)
|
||||
lastSeq Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([company, year, type])
|
||||
@@index([company, year])
|
||||
}
|
||||
|
||||
model RegistryAudit {
|
||||
id String @id @default(uuid())
|
||||
entryId String
|
||||
entryNumber String
|
||||
action String // created, updated, reserved_created, reserved_claimed, late_registration, closed, deleted
|
||||
actor String
|
||||
actorName String?
|
||||
company String
|
||||
detail Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([entryId])
|
||||
@@index([company, createdAt])
|
||||
}
|
||||
|
||||
// ─── ANCPI ePay: CF Extract Orders ──────────────────────────────────
|
||||
|
||||
model CfExtract {
|
||||
id String @id @default(uuid())
|
||||
orderId String? // ePay orderId (shared across batch items)
|
||||
basketRowId Int? // ePay cart item ID
|
||||
nrCadastral String // cadastral number
|
||||
nrCF String? // CF number if different
|
||||
siruta String? // UAT SIRUTA code
|
||||
judetIndex Int // ePay county index (0-41)
|
||||
judetName String // county display name
|
||||
uatId Int // ePay UAT numeric ID
|
||||
uatName String // UAT display name
|
||||
prodId Int @default(14200)
|
||||
solicitantId String @default("14452")
|
||||
status String @default("pending") // pending|queued|cart|searching|ordering|polling|downloading|completed|failed|cancelled
|
||||
epayStatus String? // raw ePay status
|
||||
idDocument Int? // ePay document ID
|
||||
documentName String? // ePay filename
|
||||
documentDate DateTime? // when ANCPI generated
|
||||
minioPath String? // MinIO object key
|
||||
minioIndex Int? // file version index
|
||||
creditsUsed Int @default(1)
|
||||
immovableId String? // eTerra immovable ID
|
||||
immovableType String? // T/C/A
|
||||
measuredArea String?
|
||||
legalArea String?
|
||||
address String?
|
||||
gisFeatureId String? // link to GisFeature
|
||||
version Int @default(1) // increments on re-order
|
||||
expiresAt DateTime? // 30 days after documentDate
|
||||
supersededById String? // newer version id
|
||||
requestedBy String?
|
||||
errorMessage String?
|
||||
pollAttempts Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
completedAt DateTime?
|
||||
|
||||
@@index([nrCadastral])
|
||||
@@index([status])
|
||||
@@index([orderId])
|
||||
@@index([gisFeatureId])
|
||||
@@index([createdAt])
|
||||
@@index([nrCadastral, version])
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env bash
|
||||
# rebuild-overview-tiles.sh — Export all overview layers from PostGIS, generate PMTiles, upload to MinIO
|
||||
# Includes: UAT boundaries, administrativ, simplified terenuri (z10-z14), simplified cladiri (z12-z14)
|
||||
# Usage: ./scripts/rebuild-overview-tiles.sh
|
||||
# Dependencies: ogr2ogr (GDAL), tippecanoe, mc (MinIO client)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Configuration ──
|
||||
DB_HOST="${DB_HOST:-10.10.10.166}"
|
||||
DB_PORT="${DB_PORT:-5432}"
|
||||
DB_NAME="${DB_NAME:-architools_db}"
|
||||
DB_USER="${DB_USER:-architools_user}"
|
||||
DB_PASS="${DB_PASS:-stictMyFon34!_gonY}"
|
||||
|
||||
MINIO_ALIAS="${MINIO_ALIAS:-myminio}"
|
||||
MINIO_BUCKET="${MINIO_BUCKET:-tiles}"
|
||||
MINIO_ENDPOINT="${MINIO_ENDPOINT:-http://10.10.10.166:9002}"
|
||||
MINIO_ACCESS_KEY="${MINIO_ACCESS_KEY:-admin}"
|
||||
MINIO_SECRET_KEY="${MINIO_SECRET_KEY:-MinioStrongPass123}"
|
||||
|
||||
TMPDIR="${TMPDIR:-/tmp/tile-rebuild}"
|
||||
OUTPUT_FILE="overview.pmtiles"
|
||||
|
||||
PG_CONN="PG:host=${DB_HOST} port=${DB_PORT} dbname=${DB_NAME} user=${DB_USER} password=${DB_PASS}"
|
||||
|
||||
echo "[$(date -Iseconds)] Starting overview tile rebuild..."
|
||||
|
||||
# ── Setup ──
|
||||
mkdir -p "$TMPDIR"
|
||||
cd "$TMPDIR"
|
||||
|
||||
# ── Step 1: Export views from PostGIS (parallel) ──
|
||||
echo "[$(date -Iseconds)] Exporting PostGIS views to FlatGeobuf..."
|
||||
|
||||
# UAT boundaries (4 zoom levels)
|
||||
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||
uats_z0.fgb "$PG_CONN" \
|
||||
-sql "SELECT name, siruta, geom FROM gis_uats_z0 WHERE geom IS NOT NULL" &
|
||||
|
||||
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||
uats_z5.fgb "$PG_CONN" \
|
||||
-sql "SELECT name, siruta, geom FROM gis_uats_z5 WHERE geom IS NOT NULL" &
|
||||
|
||||
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||
uats_z8.fgb "$PG_CONN" \
|
||||
-sql "SELECT name, siruta, county, geom FROM gis_uats_z8 WHERE geom IS NOT NULL" &
|
||||
|
||||
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||
uats_z12.fgb "$PG_CONN" \
|
||||
-sql "SELECT name, siruta, county, geom FROM gis_uats_z12 WHERE geom IS NOT NULL" &
|
||||
|
||||
# Administrativ (intravilan, arii speciale)
|
||||
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||
administrativ.fgb "$PG_CONN" \
|
||||
-sql "SELECT object_id, siruta, layer_id, cadastral_ref, geom FROM gis_administrativ WHERE geom IS NOT NULL" &
|
||||
|
||||
# Terenuri for overview — let tippecanoe handle simplification
|
||||
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||
terenuri_overview.fgb "$PG_CONN" \
|
||||
-sql "SELECT object_id, siruta, cadastral_ref, area_value, layer_id, geom
|
||||
FROM gis_terenuri WHERE geom IS NOT NULL" &
|
||||
|
||||
# Cladiri for overview — let tippecanoe handle simplification
|
||||
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||
cladiri_overview.fgb "$PG_CONN" \
|
||||
-sql "SELECT object_id, siruta, cadastral_ref, area_value, layer_id, geom
|
||||
FROM gis_cladiri WHERE geom IS NOT NULL" &
|
||||
|
||||
wait
|
||||
echo "[$(date -Iseconds)] Export complete."
|
||||
|
||||
# ── Step 2: Generate PMTiles with tippecanoe ──
|
||||
echo "[$(date -Iseconds)] Generating PMTiles..."
|
||||
|
||||
# Per-layer zoom ranges — avoids processing features at zoom levels where they won't appear
|
||||
# UAT boundaries: only at their respective zoom bands (saves processing z13-z18 for simple polygons)
|
||||
# Terenuri/Cladiri: only z13+/z14+ (the expensive layers skip z0-z12 entirely)
|
||||
tippecanoe \
|
||||
-o "$OUTPUT_FILE" \
|
||||
-L'{"layer":"gis_uats_z0","file":"uats_z0.fgb","minzoom":0,"maxzoom":5}' \
|
||||
-L'{"layer":"gis_uats_z5","file":"uats_z5.fgb","minzoom":5,"maxzoom":8}' \
|
||||
-L'{"layer":"gis_uats_z8","file":"uats_z8.fgb","minzoom":8,"maxzoom":12}' \
|
||||
-L'{"layer":"gis_uats_z12","file":"uats_z12.fgb","minzoom":12,"maxzoom":14}' \
|
||||
-L'{"layer":"gis_administrativ","file":"administrativ.fgb","minzoom":10,"maxzoom":16}' \
|
||||
-L'{"layer":"gis_terenuri","file":"terenuri_overview.fgb","minzoom":13,"maxzoom":18}' \
|
||||
-L'{"layer":"gis_cladiri","file":"cladiri_overview.fgb","minzoom":14,"maxzoom":18}' \
|
||||
--base-zoom=18 \
|
||||
--drop-densest-as-needed \
|
||||
--detect-shared-borders \
|
||||
--no-tile-stats \
|
||||
--hilbert \
|
||||
--force
|
||||
|
||||
echo "[$(date -Iseconds)] PMTiles generated: $(du -h "$OUTPUT_FILE" | cut -f1)"
|
||||
|
||||
# ── Step 3: Upload to MinIO (atomic swap) ──
|
||||
echo "[$(date -Iseconds)] Uploading to MinIO..."
|
||||
|
||||
# Configure MinIO client alias (idempotent)
|
||||
mc alias set "$MINIO_ALIAS" "$MINIO_ENDPOINT" "$MINIO_ACCESS_KEY" "$MINIO_SECRET_KEY" --api S3v4 2>/dev/null || true
|
||||
|
||||
# Ensure bucket exists
|
||||
mc mb --ignore-existing "${MINIO_ALIAS}/${MINIO_BUCKET}" 2>/dev/null || true
|
||||
|
||||
# Upload as temp file first
|
||||
mc cp "$OUTPUT_FILE" "${MINIO_ALIAS}/${MINIO_BUCKET}/overview_new.pmtiles"
|
||||
|
||||
# Atomic rename (zero-downtime swap)
|
||||
mc mv "${MINIO_ALIAS}/${MINIO_BUCKET}/overview_new.pmtiles" "${MINIO_ALIAS}/${MINIO_BUCKET}/overview.pmtiles"
|
||||
|
||||
echo "[$(date -Iseconds)] Upload complete."
|
||||
|
||||
# ── Step 4: Cleanup ──
|
||||
rm -f uats_z0.fgb uats_z5.fgb uats_z8.fgb uats_z12.fgb administrativ.fgb \
|
||||
terenuri_overview.fgb cladiri_overview.fgb "$OUTPUT_FILE"
|
||||
echo "[$(date -Iseconds)] Rebuild finished successfully."
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
# tile-cache-stats.sh — Show tile cache hit/miss statistics
|
||||
# Usage: ./scripts/tile-cache-stats.sh [MINUTES]
|
||||
# Reads recent nginx logs from tile-cache container.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MINUTES="${1:-60}"
|
||||
|
||||
echo "=== Tile Cache Stats (last ${MINUTES}min) ==="
|
||||
echo ""
|
||||
|
||||
# Get nginx status (active connections)
|
||||
echo "--- Connections ---"
|
||||
curl -s "http://10.10.10.166:3010/status" 2>/dev/null || echo "(status endpoint unavailable)"
|
||||
echo ""
|
||||
|
||||
# Parse recent logs for cache hit/miss ratio
|
||||
echo "--- Cache Performance ---"
|
||||
docker logs tile-cache --since "${MINUTES}m" 2>/dev/null | \
|
||||
grep -oP 'cache=\K\w+' | sort | uniq -c | sort -rn || echo "(no logs in timeframe)"
|
||||
|
||||
echo ""
|
||||
echo "--- Cache Size ---"
|
||||
docker exec tile-cache du -sh /var/cache/nginx/tiles/ 2>/dev/null || echo "(cannot read cache dir)"
|
||||
|
||||
echo ""
|
||||
echo "--- Slowest Tiles (>1s) ---"
|
||||
docker logs tile-cache --since "${MINUTES}m" 2>/dev/null | \
|
||||
grep -oP 'time=\K[0-9.]+' | awk '$1 > 1.0 {print $1"s"}' | sort -rn | head -5 || echo "(none)"
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# warm-tile-cache.sh — Pre-populate nginx tile cache with common tiles
|
||||
# Usage: ./scripts/warm-tile-cache.sh [BASE_URL]
|
||||
# Run after deploy or cache purge to ensure fast first-load for users.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE="${1:-http://10.10.10.166:3010}"
|
||||
PARALLEL="${PARALLEL:-8}"
|
||||
TOTAL=0
|
||||
HITS=0
|
||||
|
||||
echo "[$(date -Iseconds)] Warming tile cache at $BASE ..."
|
||||
|
||||
# ── Helper: fetch a range of tiles ──
|
||||
fetch_tiles() {
|
||||
local source="$1" z="$2" x_min="$3" x_max="$4" y_min="$5" y_max="$6"
|
||||
for x in $(seq "$x_min" "$x_max"); do
|
||||
for y in $(seq "$y_min" "$y_max"); do
|
||||
echo "${BASE}/${source}/${z}/${x}/${y}"
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
# ── Romania bounding box at various zoom levels ──
|
||||
# Lon: 20.2-30.0, Lat: 43.5-48.3
|
||||
# Tile coords computed from slippy map formula
|
||||
|
||||
# z5: UATs coarse (2 tiles)
|
||||
fetch_tiles gis_uats_z5 5 17 18 11 11
|
||||
|
||||
# z7: UATs moderate (12 tiles)
|
||||
fetch_tiles gis_uats_z8 7 69 73 44 46
|
||||
|
||||
# z8: UATs + labels (40 tiles)
|
||||
fetch_tiles gis_uats_z8 8 139 147 88 92
|
||||
|
||||
# z9: UATs labels (100 tiles — major cities area)
|
||||
fetch_tiles gis_uats_z8 9 279 288 177 185
|
||||
|
||||
# z10: Administrativ + terenuri sources start loading
|
||||
# Focus on major metro areas: Bucharest, Cluj, Timisoara, Iasi, Brasov
|
||||
# Bucharest area (z12)
|
||||
fetch_tiles gis_terenuri 12 2300 2310 1490 1498
|
||||
fetch_tiles gis_cladiri 12 2300 2310 1490 1498
|
||||
# Cluj area (z12)
|
||||
fetch_tiles gis_terenuri 12 2264 2270 1460 1465
|
||||
fetch_tiles gis_cladiri 12 2264 2270 1460 1465
|
||||
|
||||
echo "[$(date -Iseconds)] Fetching tiles ($PARALLEL concurrent)..."
|
||||
|
||||
# Pipe all URLs through xargs+curl for parallel fetching
|
||||
fetch_tiles gis_uats_z5 5 17 18 11 11
|
||||
fetch_tiles gis_uats_z8 7 69 73 44 46
|
||||
fetch_tiles gis_uats_z8 8 139 147 88 92
|
||||
fetch_tiles gis_uats_z8 9 279 288 177 185
|
||||
fetch_tiles gis_terenuri 12 2300 2310 1490 1498
|
||||
fetch_tiles gis_cladiri 12 2300 2310 1490 1498
|
||||
fetch_tiles gis_terenuri 12 2264 2270 1460 1465
|
||||
fetch_tiles gis_cladiri 12 2264 2270 1460 1465
|
||||
|
||||
# Actually execute all fetches
|
||||
{
|
||||
fetch_tiles gis_uats_z5 5 17 18 11 11
|
||||
fetch_tiles gis_uats_z8 7 69 73 44 46
|
||||
fetch_tiles gis_uats_z8 8 139 147 88 92
|
||||
fetch_tiles gis_uats_z8 9 279 288 177 185
|
||||
fetch_tiles gis_terenuri 12 2300 2310 1490 1498
|
||||
fetch_tiles gis_cladiri 12 2300 2310 1490 1498
|
||||
fetch_tiles gis_terenuri 12 2264 2270 1460 1465
|
||||
fetch_tiles gis_cladiri 12 2264 2270 1460 1465
|
||||
} | xargs -P "$PARALLEL" -I {} curl -sf -o /dev/null {} 2>/dev/null
|
||||
|
||||
echo "[$(date -Iseconds)] Cache warming complete."
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { FeatureGate } from "@/core/feature-flags";
|
||||
import { GeoportalModule } from "@/modules/geoportal";
|
||||
|
||||
export default function GeoportalPage() {
|
||||
return (
|
||||
<FeatureGate flag="module.geoportal" fallback={<ModuleDisabled />}>
|
||||
<GeoportalModule />
|
||||
</FeatureGate>
|
||||
);
|
||||
}
|
||||
|
||||
function ModuleDisabled() {
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl py-12 text-center text-muted-foreground">
|
||||
<p>Modulul Geoportal este dezactivat.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
|
||||
type MonitorData = {
|
||||
timestamp: string;
|
||||
nginx?: { activeConnections?: number; requests?: number; reading?: number; writing?: number; waiting?: number; error?: string };
|
||||
martin?: { status?: string; sources?: string[]; sourceCount?: number; error?: string };
|
||||
pmtiles?: { url?: string; status?: string; size?: string; lastModified?: string; error?: string };
|
||||
cacheTests?: { tile: string; status: string; cache: string }[];
|
||||
config?: { martinUrl?: string; pmtilesUrl?: string; n8nWebhook?: string };
|
||||
};
|
||||
|
||||
type EterraSessionStatus = {
|
||||
connected: boolean;
|
||||
username?: string;
|
||||
connectedAt?: string;
|
||||
activeJobCount: number;
|
||||
eterraAvailable?: boolean;
|
||||
eterraMaintenance?: boolean;
|
||||
eterraHealthMessage?: string;
|
||||
};
|
||||
|
||||
type GisStats = {
|
||||
totalUats: number;
|
||||
totalFeatures: number;
|
||||
totalTerenuri: number;
|
||||
totalCladiri: number;
|
||||
totalEnriched: number;
|
||||
totalNoGeom: number;
|
||||
countiesWithData: number;
|
||||
lastSyncAt: string | null;
|
||||
dbSizeMb: number | null;
|
||||
};
|
||||
|
||||
export default function MonitorPage() {
|
||||
const [data, setData] = useState<MonitorData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState("");
|
||||
const [logs, setLogs] = useState<{ time: string; type: "info" | "ok" | "error" | "wait"; msg: string }[]>([]);
|
||||
const [counties, setCounties] = useState<string[]>([]);
|
||||
const [selectedCounty, setSelectedCounty] = useState("");
|
||||
const [eterraSession, setEterraSession] = useState<EterraSessionStatus>({ connected: false, activeJobCount: 0 });
|
||||
const [eterraConnecting, setEterraConnecting] = useState(false);
|
||||
const [showLoginForm, setShowLoginForm] = useState(false);
|
||||
const [eterraUser, setEterraUser] = useState("");
|
||||
const [eterraPwd, setEterraPwd] = useState("");
|
||||
const [gisStats, setGisStats] = useState<GisStats | null>(null);
|
||||
const rebuildPrevRef = useRef<string | null>(null);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/geoportal/monitor");
|
||||
if (res.ok) setData(await res.json());
|
||||
} catch { /* noop */ }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
// Auto-refresh every 30s
|
||||
useEffect(() => {
|
||||
const interval = setInterval(refresh, 30_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [refresh]);
|
||||
|
||||
const addLog = useCallback((type: "info" | "ok" | "error" | "wait", msg: string) => {
|
||||
setLogs((prev) => [{ time: new Date().toLocaleTimeString("ro-RO"), type, msg }, ...prev.slice(0, 49)]);
|
||||
}, []);
|
||||
|
||||
// Fetch counties for sync selector
|
||||
useEffect(() => {
|
||||
fetch("/api/eterra/counties")
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
||||
.then((d: { counties: string[] }) => setCounties(d.counties ?? []))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// eTerra session status — poll every 30s
|
||||
const fetchEterraSession = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/eterra/session");
|
||||
if (res.ok) setEterraSession(await res.json() as EterraSessionStatus);
|
||||
} catch { /* noop */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchEterraSession();
|
||||
const interval = setInterval(() => void fetchEterraSession(), 30_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchEterraSession]);
|
||||
|
||||
// GIS stats — poll every 30s
|
||||
const fetchGisStats = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/eterra/stats");
|
||||
if (res.ok) setGisStats(await res.json() as GisStats);
|
||||
} catch { /* noop */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchGisStats();
|
||||
const interval = setInterval(() => void fetchGisStats(), 30_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchGisStats]);
|
||||
|
||||
const handleEterraConnect = async () => {
|
||||
setEterraConnecting(true);
|
||||
try {
|
||||
const payload: Record<string, string> = { action: "connect" };
|
||||
if (eterraUser.trim()) payload.username = eterraUser.trim();
|
||||
if (eterraPwd.trim()) payload.password = eterraPwd.trim();
|
||||
const res = await fetch("/api/eterra/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const d = await res.json() as { success?: boolean; error?: string };
|
||||
if (d.success) {
|
||||
await fetchEterraSession();
|
||||
addLog("ok", "eTerra conectat");
|
||||
setShowLoginForm(false);
|
||||
setEterraPwd("");
|
||||
} else {
|
||||
addLog("error", `eTerra: ${d.error ?? "Eroare conectare"}`);
|
||||
}
|
||||
} catch {
|
||||
addLog("error", "eTerra: eroare retea");
|
||||
}
|
||||
setEterraConnecting(false);
|
||||
};
|
||||
|
||||
const handleEterraDisconnect = async () => {
|
||||
const res = await fetch("/api/eterra/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "disconnect" }),
|
||||
});
|
||||
const d = await res.json() as { success?: boolean; error?: string };
|
||||
if (d.success) {
|
||||
setEterraSession({ connected: false, activeJobCount: 0 });
|
||||
addLog("info", "eTerra deconectat");
|
||||
} else {
|
||||
addLog("error", `Deconectare: ${d.error ?? "Eroare"}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Cleanup poll on unmount
|
||||
useEffect(() => {
|
||||
return () => { if (pollRef.current) clearInterval(pollRef.current); };
|
||||
}, []);
|
||||
|
||||
const triggerRebuild = async () => {
|
||||
setActionLoading("rebuild");
|
||||
addLog("info", "Se trimite webhook la N8N...");
|
||||
try {
|
||||
const res = await fetch("/api/geoportal/monitor", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "rebuild" }),
|
||||
});
|
||||
const result = await res.json() as { ok?: boolean; error?: string; alreadyRunning?: boolean; previousPmtiles?: { lastModified: string } };
|
||||
if (!result.ok) {
|
||||
addLog("error", result.error ?? "Eroare necunoscuta");
|
||||
setActionLoading("");
|
||||
return;
|
||||
}
|
||||
addLog("ok", result.alreadyRunning
|
||||
? "Rebuild deja in curs. Se monitorizeaza..."
|
||||
: "Webhook trimis. Rebuild pornit...");
|
||||
rebuildPrevRef.current = result.previousPmtiles?.lastModified ?? null;
|
||||
// Poll every 15s to check if PMTiles was updated
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const checkRes = await fetch("/api/geoportal/monitor", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "check-rebuild", previousLastModified: rebuildPrevRef.current }),
|
||||
});
|
||||
const check = await checkRes.json() as { changed?: boolean; current?: { size: string; lastModified: string } };
|
||||
if (check.changed) {
|
||||
addLog("ok", `Rebuild finalizat! PMTiles: ${check.current?.size}, actualizat: ${check.current?.lastModified}`);
|
||||
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
|
||||
setActionLoading("");
|
||||
refresh();
|
||||
}
|
||||
} catch { /* continue polling */ }
|
||||
}, 15_000);
|
||||
// Timeout after 90 min (z18 builds can take 45-60 min)
|
||||
setTimeout(() => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
addLog("error", "Timeout: rebuild nu s-a finalizat in 90 minute");
|
||||
setActionLoading("");
|
||||
}
|
||||
}, 90 * 60_000);
|
||||
} catch {
|
||||
addLog("error", "Nu s-a putut trimite webhook-ul");
|
||||
setActionLoading("");
|
||||
}
|
||||
};
|
||||
|
||||
const triggerWarmCache = async () => {
|
||||
setActionLoading("warm-cache");
|
||||
addLog("info", "Se incarca tile-uri in cache...");
|
||||
try {
|
||||
const res = await fetch("/api/geoportal/monitor", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "warm-cache" }),
|
||||
});
|
||||
const result = await res.json() as { ok?: boolean; error?: string; total?: number; hits?: number; misses?: number; errors?: number; message?: string };
|
||||
if (result.ok) {
|
||||
addLog("ok", result.message ?? "Cache warming finalizat");
|
||||
} else {
|
||||
addLog("error", result.error ?? "Eroare");
|
||||
}
|
||||
} catch {
|
||||
addLog("error", "Eroare la warm cache");
|
||||
}
|
||||
setActionLoading("");
|
||||
setTimeout(refresh, 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Tile Infrastructure Monitor</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{data?.timestamp ? `Ultima actualizare: ${new Date(data.timestamp).toLocaleTimeString("ro-RO")}` : "Se incarca..."}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 rounded bg-primary text-primary-foreground text-sm hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "..." : "Reincarca"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Nginx Card */}
|
||||
<Card title="nginx Tile Cache">
|
||||
{data?.nginx?.error ? (
|
||||
<StatusBadge status="error" label={data.nginx.error} />
|
||||
) : data?.nginx ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<StatusBadge status="ok" label="Online" />
|
||||
<div className="grid grid-cols-2 gap-1 mt-2">
|
||||
<Stat label="Conexiuni active" value={data.nginx.activeConnections} />
|
||||
<Stat label="Total requests" value={data.nginx.requests?.toLocaleString()} />
|
||||
<Stat label="Reading" value={data.nginx.reading} />
|
||||
<Stat label="Writing" value={data.nginx.writing} />
|
||||
<Stat label="Waiting" value={data.nginx.waiting} />
|
||||
</div>
|
||||
</div>
|
||||
) : <Skeleton />}
|
||||
</Card>
|
||||
|
||||
{/* Martin Card */}
|
||||
<Card title="Martin Tile Server">
|
||||
{data?.martin?.error ? (
|
||||
<StatusBadge status="error" label={data.martin.error} />
|
||||
) : data?.martin ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<StatusBadge status="ok" label={`${data.martin.sourceCount} surse active`} />
|
||||
<div className="mt-2 space-y-1">
|
||||
{data.martin.sources?.map((s) => (
|
||||
<span key={s} className="inline-block mr-1 mb-1 px-2 py-0.5 rounded bg-muted text-xs">{s}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : <Skeleton />}
|
||||
</Card>
|
||||
|
||||
{/* PMTiles Card */}
|
||||
<Card title="PMTiles Overview">
|
||||
{data?.pmtiles?.error ? (
|
||||
<StatusBadge status="error" label={data.pmtiles.error} />
|
||||
) : data?.pmtiles?.status === "not configured" ? (
|
||||
<StatusBadge status="warn" label="Nu e configurat" />
|
||||
) : data?.pmtiles ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<StatusBadge status="ok" label={data.pmtiles.size ?? "OK"} />
|
||||
<Stat label="Ultima modificare" value={data.pmtiles.lastModified} />
|
||||
</div>
|
||||
) : <Skeleton />}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Cache Test Results */}
|
||||
<Card title="Cache Test">
|
||||
{data?.cacheTests ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className="py-2 pr-4">Tile</th>
|
||||
<th className="py-2 pr-4">HTTP</th>
|
||||
<th className="py-2">Cache</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.cacheTests.map((t, i) => (
|
||||
<tr key={i} className="border-b border-border/50">
|
||||
<td className="py-2 pr-4 font-mono text-xs">{t.tile}</td>
|
||||
<td className="py-2 pr-4">{t.status}</td>
|
||||
<td className="py-2">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
t.cache === "HIT" ? "bg-green-500/20 text-green-400" :
|
||||
t.cache === "MISS" ? "bg-yellow-500/20 text-yellow-400" :
|
||||
"bg-red-500/20 text-red-400"
|
||||
}`}>
|
||||
{t.cache}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : <Skeleton />}
|
||||
</Card>
|
||||
|
||||
{/* eTerra Connection + Live Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{/* Connection card */}
|
||||
<Card title="Conexiune eTerra">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${
|
||||
eterraSession.eterraMaintenance ? "bg-yellow-400 animate-pulse" :
|
||||
eterraSession.connected ? "bg-green-400" : "bg-red-400"
|
||||
}`} />
|
||||
<span className="text-sm font-medium">
|
||||
{eterraSession.eterraMaintenance ? "Mentenanta" :
|
||||
eterraSession.connected ? (eterraSession.username || "Conectat") : "Deconectat"}
|
||||
</span>
|
||||
</div>
|
||||
{eterraSession.connected && eterraSession.connectedAt && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Conectat de la {new Date(eterraSession.connectedAt).toLocaleTimeString("ro-RO")}
|
||||
</div>
|
||||
)}
|
||||
{eterraSession.connected && eterraSession.activeJobCount > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
|
||||
<span>{eterraSession.activeJobCount} {eterraSession.activeJobCount === 1 ? "job activ" : "joburi active"}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="pt-1">
|
||||
{eterraSession.connected ? (
|
||||
<button
|
||||
onClick={handleEterraDisconnect}
|
||||
className="w-full text-xs px-3 py-1.5 rounded-md border border-border hover:bg-destructive/10 hover:text-destructive hover:border-destructive/30 transition-colors"
|
||||
>
|
||||
Deconecteaza
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowLoginForm((v) => !v)}
|
||||
className="w-full text-xs px-3 py-1.5 rounded-md border border-border hover:border-primary/50 hover:bg-primary/10 transition-colors"
|
||||
>
|
||||
{showLoginForm ? "Anuleaza" : "Conecteaza"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showLoginForm && !eterraSession.connected && (
|
||||
<div className="space-y-2 pt-1">
|
||||
<input
|
||||
type="text"
|
||||
value={eterraUser}
|
||||
onChange={(e) => setEterraUser(e.target.value)}
|
||||
className="h-8 w-full rounded-md border border-border bg-background px-2 text-sm"
|
||||
placeholder="Utilizator eTerra"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={eterraPwd}
|
||||
onChange={(e) => setEterraPwd(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleEterraConnect(); }}
|
||||
className="h-8 w-full rounded-md border border-border bg-background px-2 text-sm"
|
||||
placeholder="Parola"
|
||||
/>
|
||||
<button
|
||||
onClick={handleEterraConnect}
|
||||
disabled={eterraConnecting || !eterraUser.trim() || !eterraPwd.trim()}
|
||||
className="w-full h-8 rounded-md bg-primary text-primary-foreground text-xs hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{eterraConnecting ? "Se conecteaza..." : "Login"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Live stats cards */}
|
||||
<StatCard
|
||||
label="UAT-uri"
|
||||
value={gisStats?.totalUats}
|
||||
sub={gisStats?.countiesWithData ? `din ${gisStats.countiesWithData} judete` : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
label="Parcele"
|
||||
value={gisStats?.totalTerenuri}
|
||||
sub={gisStats?.totalEnriched ? `${gisStats.totalEnriched.toLocaleString("ro-RO")} enriched` : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
label="Cladiri"
|
||||
value={gisStats?.totalCladiri}
|
||||
sub={gisStats?.dbSizeMb ? `DB: ${gisStats.dbSizeMb >= 1024 ? `${(gisStats.dbSizeMb / 1024).toFixed(1)} GB` : `${gisStats.dbSizeMb} MB`}` : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{gisStats?.lastSyncAt && (
|
||||
<div className="text-xs text-muted-foreground text-right -mt-2">
|
||||
Ultimul sync: {new Date(gisStats.lastSyncAt).toLocaleString("ro-RO")} — auto-refresh 30s
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<Card title="Actiuni">
|
||||
{/* Tile infrastructure actions */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">Tile-uri</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<ActionButton
|
||||
label="Rebuild PMTiles"
|
||||
description="Regenereaza tile-urile overview din PostGIS (~45-60 min)"
|
||||
loading={actionLoading === "rebuild"}
|
||||
onClick={triggerRebuild}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Warm Cache"
|
||||
description="Pre-incarca tile-uri frecvente in nginx cache"
|
||||
loading={actionLoading === "warm-cache"}
|
||||
onClick={triggerWarmCache}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sync actions */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Sincronizare eTerra</h3>
|
||||
<a
|
||||
href="/sync-management"
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
Gestioneaza reguli sync
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<SyncTestButton
|
||||
label="Sync All Romania"
|
||||
description={`Toate judetele (${gisStats?.countiesWithData ?? "?"} jud, ${gisStats?.totalUats ?? "?"} UAT) — poate dura ore`}
|
||||
siruta=""
|
||||
mode="base"
|
||||
includeNoGeometry={false}
|
||||
actionKey="sync-all-counties"
|
||||
actionLoading={actionLoading}
|
||||
setActionLoading={setActionLoading}
|
||||
addLog={addLog}
|
||||
pollRef={pollRef}
|
||||
customEndpoint="/api/eterra/sync-all-counties"
|
||||
/>
|
||||
<SyncTestButton
|
||||
label="Refresh ALL UATs"
|
||||
description={`Delta sync pe toate ${gisStats?.totalUats ?? "?"} UAT-urile din DB`}
|
||||
siruta=""
|
||||
mode="base"
|
||||
includeNoGeometry={false}
|
||||
actionKey="refresh-all"
|
||||
actionLoading={actionLoading}
|
||||
setActionLoading={setActionLoading}
|
||||
addLog={addLog}
|
||||
pollRef={pollRef}
|
||||
customEndpoint="/api/eterra/refresh-all"
|
||||
/>
|
||||
<SyncTestButton
|
||||
label="Test Delta — Cluj-Napoca"
|
||||
description="Parcele + cladiri existente, fara magic (54975)"
|
||||
siruta="54975"
|
||||
mode="base"
|
||||
includeNoGeometry={false}
|
||||
actionKey="delta-cluj-base"
|
||||
actionLoading={actionLoading}
|
||||
setActionLoading={setActionLoading}
|
||||
addLog={addLog}
|
||||
pollRef={pollRef}
|
||||
/>
|
||||
<SyncTestButton
|
||||
label="Test Delta — Feleacu"
|
||||
description="Magic + no-geom, cu enrichment (57582)"
|
||||
siruta="57582"
|
||||
mode="magic"
|
||||
includeNoGeometry={true}
|
||||
actionKey="delta-feleacu-magic"
|
||||
actionLoading={actionLoading}
|
||||
setActionLoading={setActionLoading}
|
||||
addLog={addLog}
|
||||
pollRef={pollRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* County sync */}
|
||||
<div>
|
||||
<h3 className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">Sync pe judet</h3>
|
||||
<div className="flex items-end gap-3">
|
||||
<select
|
||||
value={selectedCounty}
|
||||
onChange={(e) => setSelectedCounty(e.target.value)}
|
||||
className="h-9 w-52 rounded-md border border-border bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="">Alege judet...</option>
|
||||
{counties.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
<SyncTestButton
|
||||
label={selectedCounty ? `Sync ${selectedCounty}` : "Sync Judet"}
|
||||
description="TERENURI + CLADIRI + INTRAVILAN pentru tot judetul"
|
||||
siruta=""
|
||||
mode="base"
|
||||
includeNoGeometry={false}
|
||||
actionKey="sync-county"
|
||||
actionLoading={actionLoading}
|
||||
setActionLoading={setActionLoading}
|
||||
addLog={addLog}
|
||||
pollRef={pollRef}
|
||||
customEndpoint="/api/eterra/sync-county"
|
||||
customBody={{ county: selectedCounty }}
|
||||
disabled={!selectedCounty}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Activity Log */}
|
||||
{logs.length > 0 && (
|
||||
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-muted/50 border-b border-border">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Log activitate</span>
|
||||
<button onClick={() => setLogs([])} className="text-xs text-muted-foreground hover:text-foreground transition-colors">Sterge</button>
|
||||
</div>
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
{logs.map((log, i) => (
|
||||
<div key={i} className="flex items-start gap-2 px-4 py-1.5 text-xs border-b border-border/30 last:border-0">
|
||||
<span className="text-muted-foreground shrink-0 font-mono">{log.time}</span>
|
||||
<span className={`shrink-0 ${
|
||||
log.type === "ok" ? "text-green-400" :
|
||||
log.type === "error" ? "text-red-400" :
|
||||
log.type === "wait" ? "text-yellow-400" :
|
||||
"text-blue-400"
|
||||
}`}>
|
||||
{log.type === "ok" ? "[OK]" : log.type === "error" ? "[ERR]" : log.type === "wait" ? "[...]" : "[i]"}
|
||||
</span>
|
||||
<span>{log.msg}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config */}
|
||||
<Card title="Configuratie">
|
||||
{data?.config ? (
|
||||
<div className="space-y-1 text-sm font-mono">
|
||||
<div><span className="text-muted-foreground">MARTIN_URL:</span> {data.config.martinUrl}</div>
|
||||
<div><span className="text-muted-foreground">PMTILES_URL:</span> {data.config.pmtilesUrl}</div>
|
||||
<div><span className="text-muted-foreground">N8N_WEBHOOK:</span> {data.config.n8nWebhook}</div>
|
||||
</div>
|
||||
) : <Skeleton />}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- Sub-components ---- */
|
||||
|
||||
function Card({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-3">{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status, label }: { status: "ok" | "error" | "warn"; label: string }) {
|
||||
const colors = {
|
||||
ok: "bg-green-500/20 text-green-400",
|
||||
error: "bg-red-500/20 text-red-400",
|
||||
warn: "bg-yellow-500/20 text-yellow-400",
|
||||
};
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium ${colors[status]}`}>
|
||||
<span className={`w-2 h-2 rounded-full ${status === "ok" ? "bg-green-400" : status === "error" ? "bg-red-400" : "bg-yellow-400"}`} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value?: string | number | null }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs">{label}</div>
|
||||
<div className="font-medium">{value ?? "-"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Skeleton() {
|
||||
return <div className="h-16 rounded bg-muted/50 animate-pulse" />;
|
||||
}
|
||||
|
||||
function StatCard({ label, value, sub }: { label: string; value?: number | null; sub?: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<div className="text-xs text-muted-foreground mb-1">{label}</div>
|
||||
<div className="text-2xl font-bold tabular-nums">
|
||||
{value != null ? value.toLocaleString("ro-RO") : <span className="text-muted-foreground">--</span>}
|
||||
</div>
|
||||
{sub && <div className="text-xs text-muted-foreground mt-1">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({ label, description, loading, onClick }: {
|
||||
label: string; description: string; loading: boolean; onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={loading}
|
||||
className="flex flex-col items-start px-4 py-3 rounded-lg border border-border hover:border-primary/50 hover:bg-primary/5 transition-colors disabled:opacity-50 text-left"
|
||||
>
|
||||
<span className="font-medium text-sm">{loading ? "Se ruleaza..." : label}</span>
|
||||
<span className="text-xs text-muted-foreground">{description}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SyncTestButton({ label, description, siruta, mode, includeNoGeometry, actionKey, actionLoading, setActionLoading, addLog, pollRef, customEndpoint, customBody, disabled }: {
|
||||
label: string; description: string; siruta: string; mode: "base" | "magic";
|
||||
includeNoGeometry: boolean; actionKey: string; actionLoading: string;
|
||||
setActionLoading: (v: string) => void;
|
||||
addLog: (type: "info" | "ok" | "error" | "wait", msg: string) => void;
|
||||
pollRef: React.MutableRefObject<ReturnType<typeof setInterval> | null>;
|
||||
customEndpoint?: string;
|
||||
customBody?: Record<string, unknown>;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const startTimeRef = useRef<number>(0);
|
||||
const formatElapsed = () => {
|
||||
if (!startTimeRef.current) return "";
|
||||
const s = Math.round((Date.now() - startTimeRef.current) / 1000);
|
||||
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={async () => {
|
||||
setActionLoading(actionKey);
|
||||
startTimeRef.current = Date.now();
|
||||
addLog("info", `[${label}] Pornire...`);
|
||||
try {
|
||||
const endpoint = customEndpoint ?? "/api/eterra/sync-background";
|
||||
const body = customEndpoint ? (customBody ?? {}) : { siruta, mode, includeNoGeometry };
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const d = await res.json() as { jobId?: string; error?: string };
|
||||
if (!res.ok) {
|
||||
addLog("error", `[${label}] ${d.error ?? "Eroare start"}`);
|
||||
setActionLoading(""); return;
|
||||
}
|
||||
addLog("ok", `[${label}] Job: ${d.jobId?.slice(0, 8)}`);
|
||||
const jid = d.jobId;
|
||||
let lastPhase = "";
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const pr = await fetch(`/api/eterra/progress?jobId=${jid}`);
|
||||
const pg = await pr.json() as { status?: string; phase?: string; downloaded?: number; total?: number; note?: string; message?: string };
|
||||
const pct = pg.total ? Math.round(((pg.downloaded ?? 0) / pg.total) * 100) : 0;
|
||||
const elapsed = formatElapsed();
|
||||
const phaseChanged = pg.phase !== lastPhase;
|
||||
if (phaseChanged) lastPhase = pg.phase ?? "";
|
||||
// Only log phase changes and completion to keep log clean
|
||||
if (phaseChanged || pg.status === "done" || pg.status === "error") {
|
||||
const noteStr = pg.note ? ` — ${pg.note}` : "";
|
||||
addLog(
|
||||
pg.status === "done" ? "ok" : pg.status === "error" ? "error" : "wait",
|
||||
`[${label}] ${elapsed} | ${pg.phase ?? "..."} (${pct}%)${noteStr}`,
|
||||
);
|
||||
}
|
||||
if (pg.status === "done" || pg.status === "error") {
|
||||
const totalTime = formatElapsed();
|
||||
addLog(pg.status === "done" ? "ok" : "error",
|
||||
`[${label}] TOTAL: ${totalTime}${pg.message ? " — " + pg.message : ""}`,
|
||||
);
|
||||
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
|
||||
setActionLoading("");
|
||||
}
|
||||
} catch { /* continue */ }
|
||||
}, 3000);
|
||||
setTimeout(() => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current); pollRef.current = null;
|
||||
addLog("error", `[${label}] Timeout 3h (${formatElapsed()})`);
|
||||
setActionLoading("");
|
||||
}
|
||||
}, 3 * 60 * 60_000);
|
||||
} catch { addLog("error", `[${label}] Eroare retea`); setActionLoading(""); }
|
||||
}}
|
||||
disabled={!!actionLoading || !!disabled}
|
||||
className="flex flex-col items-start px-4 py-3 rounded-lg border border-border hover:border-primary/50 hover:bg-primary/5 transition-colors disabled:opacity-50 text-left"
|
||||
>
|
||||
<span className="font-medium text-sm">{actionLoading === actionKey ? "Se ruleaza..." : label}</span>
|
||||
<span className="text-xs text-muted-foreground">{description}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,878 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/shared/components/ui/tabs";
|
||||
import {
|
||||
Copy,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Terminal,
|
||||
Bug,
|
||||
Sparkles,
|
||||
Shield,
|
||||
TestTube,
|
||||
Plug,
|
||||
RefreshCw,
|
||||
FileText,
|
||||
Zap,
|
||||
Rocket,
|
||||
ListChecks,
|
||||
Brain,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────
|
||||
|
||||
type PromptCategory = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
description: string;
|
||||
prompts: PromptTemplate[];
|
||||
};
|
||||
|
||||
type PromptTemplate = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
tags: string[];
|
||||
oneTime?: boolean;
|
||||
variables?: string[];
|
||||
};
|
||||
|
||||
// ─── Module list for variable substitution hints ───────────────────
|
||||
|
||||
const MODULES = [
|
||||
"registratura", "address-book", "parcel-sync", "geoportal", "password-vault",
|
||||
"mini-utilities", "email-signature", "word-xml", "word-templates", "tag-manager",
|
||||
"it-inventory", "digital-signatures", "prompt-generator", "ai-chat", "hot-desk",
|
||||
"visual-copilot", "dashboard",
|
||||
] as const;
|
||||
|
||||
// ─── Prompt Templates ──────────────────────────────────────────────
|
||||
|
||||
const CATEGORIES: PromptCategory[] = [
|
||||
{
|
||||
id: "module-work",
|
||||
label: "Lucru pe modul",
|
||||
icon: <Terminal className="size-4" />,
|
||||
description: "Prompturi pentru lucru general, bugfix-uri si features pe module existente",
|
||||
prompts: [
|
||||
{
|
||||
id: "module-work-general",
|
||||
title: "Lucru general pe modul",
|
||||
description: "Sesiune de lucru pe un modul specific — citeste contextul, propune, implementeaza",
|
||||
tags: ["regular", "module"],
|
||||
variables: ["MODULE_NAME"],
|
||||
prompt: `Scopul acestei sesiuni este sa lucram pe modulul {MODULE_NAME} din ArchiTools.
|
||||
|
||||
Citeste CLAUDE.md si docs/MODULE-MAP.md inainte de orice.
|
||||
Apoi citeste tipurile si componentele modulului:
|
||||
- src/modules/{MODULE_NAME}/types.ts
|
||||
- src/modules/{MODULE_NAME}/config.ts
|
||||
- src/modules/{MODULE_NAME}/components/ (fisierele principale)
|
||||
- src/modules/{MODULE_NAME}/services/ (daca exista)
|
||||
|
||||
Dupa ce ai inteles codul existent, intreaba-ma ce vreau sa fac.
|
||||
Nu propune schimbari pana nu intelegi modulul complet.
|
||||
npx next build TREBUIE sa treaca dupa fiecare schimbare.`,
|
||||
},
|
||||
{
|
||||
id: "bugfix",
|
||||
title: "Bugfix pe modul",
|
||||
description: "Investigheaza si rezolva un bug specific",
|
||||
tags: ["regular", "bugfix"],
|
||||
variables: ["MODULE_NAME", "BUG_DESCRIPTION"],
|
||||
prompt: `Am un bug in modulul {MODULE_NAME}: {BUG_DESCRIPTION}
|
||||
|
||||
Citeste CLAUDE.md si docs/MODULE-MAP.md pentru context.
|
||||
|
||||
Pasi:
|
||||
1. Citeste fisierele relevante ale modulului
|
||||
2. Identifica cauza root — nu ghici, citeste codul
|
||||
3. Propune fix-ul INAINTE sa-l aplici
|
||||
4. Aplica fix-ul minimal (nu refactoriza alte lucruri)
|
||||
5. Verifica ca npx next build trece
|
||||
6. Explica ce s-a schimbat si de ce
|
||||
|
||||
Daca bug-ul e in interactiunea cu alt modul sau API, citeste si acel cod.
|
||||
Nu adauga features sau "imbunatatiri" — doar fix bug-ul raportat.`,
|
||||
},
|
||||
{
|
||||
id: "feature-existing",
|
||||
title: "Feature nou in modul existent",
|
||||
description: "Adauga o functionalitate noua intr-un modul care exista deja",
|
||||
tags: ["regular", "feature"],
|
||||
variables: ["MODULE_NAME", "FEATURE_DESCRIPTION"],
|
||||
prompt: `Vreau sa adaug un feature in modulul {MODULE_NAME}: {FEATURE_DESCRIPTION}
|
||||
|
||||
Citeste CLAUDE.md si docs/MODULE-MAP.md pentru context.
|
||||
|
||||
Pasi obligatorii:
|
||||
1. Citeste types.ts, config.ts si componentele existente ale modulului
|
||||
2. Verifica daca feature-ul necesita schimbari de tip (types.ts)
|
||||
3. Verifica daca necesita API route nou sau modificare la cel existent
|
||||
4. Propune planul de implementare INAINTE de a scrie cod
|
||||
5. Implementeaza pas cu pas, verificand build dupa fiecare fisier major
|
||||
6. Pastreaza conventiile existente (English code, Romanian UI)
|
||||
7. npx next build TREBUIE sa treaca
|
||||
|
||||
Reguli:
|
||||
- Nu schimba structura modulului fara motiv
|
||||
- Nu adauga dependinte noi daca nu e necesar
|
||||
- Pastreaza compatibilitatea cu datele existente in storage/DB
|
||||
- Daca trebuie migrare de date, propune-o separat`,
|
||||
},
|
||||
{
|
||||
id: "feature-new-module",
|
||||
title: "Modul complet nou",
|
||||
description: "Creeaza un modul nou de la zero urmand pattern-ul standard",
|
||||
tags: ["one-time", "feature", "architecture"],
|
||||
oneTime: true,
|
||||
variables: ["MODULE_NAME", "MODULE_DESCRIPTION"],
|
||||
prompt: `Vreau sa creez un modul nou: {MODULE_NAME} — {MODULE_DESCRIPTION}
|
||||
|
||||
Citeste CLAUDE.md, docs/MODULE-MAP.md si docs/guides/MODULE-DEVELOPMENT.md.
|
||||
Studiaza un modul existent similar ca referinta (ex: it-inventory sau hot-desk pentru module simple, registratura pentru module complexe).
|
||||
|
||||
Creeaza structura standard:
|
||||
src/modules/{MODULE_NAME}/
|
||||
components/{MODULE_NAME}-module.tsx
|
||||
hooks/use-{MODULE_NAME}.ts (daca e nevoie)
|
||||
services/ (daca e nevoie)
|
||||
types.ts
|
||||
config.ts
|
||||
index.ts
|
||||
|
||||
Plus:
|
||||
- src/app/(modules)/{MODULE_NAME}/page.tsx (route page)
|
||||
- Adauga config in src/config/modules.ts
|
||||
- Adauga flag in src/config/flags.ts
|
||||
- Adauga navigare in src/config/navigation.ts
|
||||
|
||||
Reguli:
|
||||
- Urmeaza EXACT pattern-ul celorlalte module
|
||||
- English code, Romanian UI text
|
||||
- Feature flag enabled by default
|
||||
- Storage via useStorage('{MODULE_NAME}') hook
|
||||
- npx next build TREBUIE sa treaca
|
||||
- Nu implementa mai mult decat MVP-ul — pot adauga dupa`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "api",
|
||||
label: "API & Backend",
|
||||
icon: <Plug className="size-4" />,
|
||||
description: "Creare si modificare API routes, integrari externe, Prisma schema",
|
||||
prompts: [
|
||||
{
|
||||
id: "api-new-route",
|
||||
title: "API route nou",
|
||||
description: "Creeaza un endpoint API nou cu auth, validare, error handling",
|
||||
tags: ["regular", "api"],
|
||||
variables: ["ROUTE_PATH", "ROUTE_DESCRIPTION"],
|
||||
prompt: `Creeaza un API route nou: /api/{ROUTE_PATH} — {ROUTE_DESCRIPTION}
|
||||
|
||||
Citeste CLAUDE.md (sectiunea Middleware & Large Uploads) si docs/ARCHITECTURE-QUICK.md.
|
||||
|
||||
Cerinte obligatorii:
|
||||
1. Auth: middleware coverage SAU requireAuth() pentru rute excluse
|
||||
2. Input validation pe toate parametrii
|
||||
3. Error handling: try/catch cu mesaje utile (nu stack traces)
|
||||
4. Prisma queries: parametrizate ($queryRaw cu template literals, NU string concat)
|
||||
5. TypeScript strict: toate return types explicit
|
||||
|
||||
Pattern de referinta — citeste un API route existent similar:
|
||||
- CRUD simplu: src/app/api/storage/route.ts
|
||||
- Cu Prisma raw: src/app/api/registratura/route.ts
|
||||
- Cu external API: src/app/api/eterra/search/route.ts
|
||||
|
||||
Daca ruta accepta uploads mari:
|
||||
- Exclude din middleware matcher (src/middleware.ts)
|
||||
- Adauga requireAuth() manual
|
||||
- Documenteaza in CLAUDE.md sectiunea Middleware
|
||||
|
||||
npx next build TREBUIE sa treaca.`,
|
||||
},
|
||||
{
|
||||
id: "prisma-schema",
|
||||
title: "Modificare Prisma schema",
|
||||
description: "Adauga model nou sau modifica schema existenta",
|
||||
tags: ["regular", "database"],
|
||||
variables: ["CHANGE_DESCRIPTION"],
|
||||
prompt: `Vreau sa modific Prisma schema: {CHANGE_DESCRIPTION}
|
||||
|
||||
Citeste prisma/schema.prisma complet inainte.
|
||||
|
||||
Pasi:
|
||||
1. Propune schimbarea de schema INAINTE de a o aplica
|
||||
2. Verifica impactul asupra codului existent (grep pentru modelul afectat)
|
||||
3. Aplica in schema.prisma
|
||||
4. Ruleaza: npx prisma generate
|
||||
5. Actualizeaza codul care foloseste modelul
|
||||
6. npx next build TREBUIE sa treaca
|
||||
|
||||
Reguli:
|
||||
- Adauga @@index pe coloane folosite in WHERE/ORDER BY
|
||||
- Adauga @@unique pe combinatii care trebuie sa fie unice
|
||||
- onDelete: SetNull sau Cascade — niciodata default (restrict)
|
||||
- Foloseste Json? pentru campuri flexibile (enrichment pattern)
|
||||
- DateTime cu @default(now()) pe createdAt, @updatedAt pe updatedAt
|
||||
|
||||
IMPORTANT: Aceasta schimbare necesita si migrare pe server (prisma migrate).
|
||||
Nu face breaking changes fara plan de migrare.`,
|
||||
},
|
||||
{
|
||||
id: "external-integration",
|
||||
title: "Integrare API extern",
|
||||
description: "Conectare la un serviciu extern (pattern eTerra/ePay)",
|
||||
tags: ["one-time", "api", "architecture"],
|
||||
oneTime: true,
|
||||
variables: ["SERVICE_NAME", "SERVICE_DESCRIPTION"],
|
||||
prompt: `Vreau sa integrez un serviciu extern: {SERVICE_NAME} — {SERVICE_DESCRIPTION}
|
||||
|
||||
Citeste CLAUDE.md sectiunile eTerra/ANCPI Rules si Middleware.
|
||||
Studiaza pattern-ul din src/modules/parcel-sync/services/eterra-client.ts ca referinta.
|
||||
|
||||
Pattern obligatoriu pentru integrari externe:
|
||||
1. Client class separat in services/ (nu inline in route)
|
||||
2. Session/token caching cu TTL (global singleton pattern)
|
||||
3. Periodic cleanup pe cache (setInterval)
|
||||
4. Health check daca serviciul e instabil
|
||||
5. Retry logic pentru erori tranziente (ECONNRESET, 500)
|
||||
6. Timeout explicit pe toate request-urile
|
||||
7. Error handling granular (nu catch-all generic)
|
||||
8. Logging cu prefix: console.log("[{SERVICE_NAME}] ...")
|
||||
|
||||
Env vars:
|
||||
- Adauga in docker-compose.yml
|
||||
- NU hardcoda credentials in cod
|
||||
- Documenteaza in docs/ARCHITECTURE-QUICK.md
|
||||
|
||||
npx next build TREBUIE sa treaca.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "quality",
|
||||
label: "Calitate & Securitate",
|
||||
icon: <Shield className="size-4" />,
|
||||
description: "Audituri de securitate, testing, performance, code review",
|
||||
prompts: [
|
||||
{
|
||||
id: "security-audit",
|
||||
title: "Audit securitate complet",
|
||||
description: "Scanare completa de securitate pe tot codebase-ul",
|
||||
tags: ["periodic", "security"],
|
||||
prompt: `Scopul acestei sesiuni este un audit complet de securitate al ArchiTools.
|
||||
Aplicatia este IN PRODUCTIE la https://tools.beletage.ro.
|
||||
|
||||
Citeste CLAUDE.md si docs/ARCHITECTURE-QUICK.md inainte de orice.
|
||||
|
||||
Scaneaza cu agenti in paralel:
|
||||
|
||||
1. API AUTH: Verifica ca TOATE rutele din src/app/api/ au auth check
|
||||
(middleware matcher + requireAuth fallback)
|
||||
2. SQL INJECTION: Cauta $queryRaw/$executeRaw cu string concatenation
|
||||
3. INPUT VALIDATION: Verifica sanitizarea pe toate endpoint-urile
|
||||
4. SECRETS: Cauta credentials hardcoded, env vars expuse in client
|
||||
5. ERROR HANDLING: Catch goale, stack traces in responses
|
||||
6. RACE CONDITIONS: Write operations concurente fara locks
|
||||
7. DATA INTEGRITY: Upsert-uri care pot suprascrie date
|
||||
|
||||
Grupeaza in: CRITICAL / IMPORTANT / NICE-TO-HAVE
|
||||
Pentru fiecare: fisier, linia, problema, solutia propusa.
|
||||
NU aplica fix-uri fara sa le listezi mai intai.
|
||||
npx next build TREBUIE sa treaca dupa fiecare fix.`,
|
||||
},
|
||||
{
|
||||
id: "security-module",
|
||||
title: "Audit securitate pe modul",
|
||||
description: "Review de securitate focusat pe un singur modul",
|
||||
tags: ["regular", "security"],
|
||||
variables: ["MODULE_NAME"],
|
||||
prompt: `Fa un audit de securitate pe modulul {MODULE_NAME}.
|
||||
|
||||
Citeste CLAUDE.md si docs/MODULE-MAP.md.
|
||||
|
||||
Verifica:
|
||||
1. API routes folosite de modul — au auth? Input validation?
|
||||
2. Prisma queries — SQL injection posibil?
|
||||
3. User input — sanitizat inainte de stocare/afisare?
|
||||
4. File uploads (daca exista) — validare tip/dimensiune?
|
||||
5. Storage operations — race conditions la concurrent access?
|
||||
6. Error handling — erori silentioase? Stack traces expuse?
|
||||
7. Cross-module deps — sunt corecte si necesare?
|
||||
|
||||
Raporteaza gasirile cu: fisier, linia, severitate, fix propus.`,
|
||||
},
|
||||
{
|
||||
id: "testing-hardcore",
|
||||
title: "Testing hardcore",
|
||||
description: "Edge cases, stress testing, error scenarios pentru un modul",
|
||||
tags: ["periodic", "testing"],
|
||||
variables: ["MODULE_NAME"],
|
||||
prompt: `Vreau sa testez hardcore modulul {MODULE_NAME}.
|
||||
|
||||
Citeste codul modulului complet, apoi gandeste:
|
||||
|
||||
1. EDGE CASES: Ce se intampla cu input gol? Cu caractere speciale (diacritice, emoji)? Cu valori extreme (numar foarte mare, string foarte lung)?
|
||||
|
||||
2. CONCURRENT ACCESS: Ce se intampla daca 2 useri fac aceeasi operatie simultan? Race conditions la write/update/delete?
|
||||
|
||||
3. ERROR PATHS: Ce se intampla daca DB-ul e down? Daca API-ul extern nu raspunde? Daca sesiunea expira mid-operation?
|
||||
|
||||
4. DATA INTEGRITY: Pot pierde date? Pot crea duplicate? Pot suprascrie datele altcuiva?
|
||||
|
||||
5. UI STATE: Ce se intampla daca user-ul da click dublu pe buton? Daca navigheaza away in timpul unui save? Daca face refresh?
|
||||
|
||||
6. STORAGE: Ce se intampla cu date legacy (format vechi)? Cu valori null/undefined in JSON?
|
||||
|
||||
Pentru fiecare problema gasita: descrie scenariul, impactul, si propune fix.
|
||||
Aplica doar ce e CRITICAL dupa aprobare.`,
|
||||
},
|
||||
{
|
||||
id: "performance-audit",
|
||||
title: "Audit performanta",
|
||||
description: "Identificare bottleneck-uri, optimizare queries, bundle size",
|
||||
tags: ["periodic", "performance"],
|
||||
variables: ["MODULE_NAME"],
|
||||
prompt: `Analizeaza performanta modulului {MODULE_NAME}.
|
||||
|
||||
Citeste CLAUDE.md (Storage Performance Rules) si codul modulului.
|
||||
|
||||
Verifica:
|
||||
1. N+1 QUERIES: storage.list() + get() in loop? Ar trebui exportAll()
|
||||
2. LARGE PAYLOADS: Se incarca date inutile? lightweight: true folosit?
|
||||
3. RE-RENDERS: useEffect-uri care trigger-uiesc re-render excesiv?
|
||||
4. BUNDLE SIZE: Import-uri heavy (librarii intregi vs tree-shaking)?
|
||||
5. API CALLS: Request-uri redundante? Lipseste caching?
|
||||
6. DB QUERIES: Lipsesc indexuri? SELECT * in loc de select specific?
|
||||
7. MEMORY: Global singletons care cresc nelimitat? Cache fara TTL?
|
||||
|
||||
Propune optimizarile ordonate dupa impact.
|
||||
Aplica doar dupa aprobare.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "session",
|
||||
label: "Sesiune & Continuare",
|
||||
icon: <RefreshCw className="size-4" />,
|
||||
description: "Prompturi pentru inceperea sau continuarea sesiunilor de lucru",
|
||||
prompts: [
|
||||
{
|
||||
id: "continue-tasklist",
|
||||
title: "Continuare din sesiunea anterioara",
|
||||
description: "Reia lucrul de unde am ramas, cu verificare task list",
|
||||
tags: ["regular", "session"],
|
||||
prompt: `Continuam din sesiunea anterioara.
|
||||
|
||||
Citeste CLAUDE.md, MEMORY.md si docs/MODULE-MAP.md.
|
||||
Verifica memory/ pentru context despre ce s-a lucrat recent.
|
||||
|
||||
Apoi:
|
||||
1. Citeste ROADMAP.md (daca exista) pentru task list-ul curent
|
||||
2. Verifica git log --oneline -20 sa vezi ce s-a comis recent
|
||||
3. Verifica git status sa vezi daca sunt schimbari uncommited
|
||||
4. Rezuma ce s-a facut si ce a ramas
|
||||
5. Intreaba-ma cum vreau sa continuam
|
||||
|
||||
Nu incepe sa lucrezi fara confirmare.
|
||||
npx next build TREBUIE sa treaca inainte de orice schimbare.`,
|
||||
},
|
||||
{
|
||||
id: "fresh-session",
|
||||
title: "Sesiune noua — orientare",
|
||||
description: "Prima sesiune sau sesiune dupa pauza lunga — ia-ti bearings",
|
||||
tags: ["regular", "session"],
|
||||
prompt: `Sesiune noua pe ArchiTools.
|
||||
|
||||
Citeste in ordine:
|
||||
1. CLAUDE.md (context proiect + reguli)
|
||||
2. memory/MEMORY.md (index memorii)
|
||||
3. Fiecare fisier din memory/ (context sesiuni anterioare)
|
||||
4. git log --oneline -20 (activitate recenta)
|
||||
5. git status (stare curenta)
|
||||
6. docs/MODULE-MAP.md (harta module)
|
||||
|
||||
Dupa ce ai citit tot, da-mi un rezumat de 5-10 randuri:
|
||||
- Ce s-a facut recent
|
||||
- Ce e in progress / neterminat
|
||||
- Ce probleme sunt cunoscute
|
||||
- Recomandarea ta pentru ce sa facem azi
|
||||
|
||||
Asteapta confirmarea mea inainte de a incepe.`,
|
||||
},
|
||||
{
|
||||
id: "review-refactor",
|
||||
title: "Code review & refactoring",
|
||||
description: "Review si curatare cod pe o zona specifica",
|
||||
tags: ["periodic", "review"],
|
||||
variables: ["TARGET"],
|
||||
prompt: `Fa code review pe: {TARGET}
|
||||
|
||||
Citeste CLAUDE.md si codul tinta complet.
|
||||
|
||||
Verifica:
|
||||
1. PATTERN COMPLIANCE: Urmeaza conventiile din CLAUDE.md?
|
||||
2. TYPE SAFETY: TypeScript strict — sunt tipuri corecte? Null checks?
|
||||
3. ERROR HANDLING: Catch blocks complete? Promise-uri handled?
|
||||
4. NAMING: English code, Romanian UI? Consistent cu restul?
|
||||
5. COMPLEXITY: Functii prea lungi? Logica duplicata?
|
||||
6. SECURITY: Input validation? Auth checks?
|
||||
7. PERFORMANCE: N+1 queries? Re-renders inutile?
|
||||
|
||||
Raporteaza gasirile ordonate dupa severitate.
|
||||
NU aplica refactoring fara listarea schimbarilor propuse si aprobare.
|
||||
Refactoring-ul trebuie sa fie minimal — nu rescrie ce functioneaza.`,
|
||||
},
|
||||
{
|
||||
id: "full-audit",
|
||||
title: "Audit complet codebase",
|
||||
description: "Scanare completa: cod mort, consistenta, securitate, documentatie",
|
||||
tags: ["periodic", "audit"],
|
||||
oneTime: true,
|
||||
prompt: `Scopul acestei sesiuni este un audit complet al codebase-ului ArchiTools cu 3 obiective:
|
||||
|
||||
1. REVIEW & CLEANUP: Cod mort, dependinte neutilizate, TODO/FIXME, consistenta module
|
||||
2. SIGURANTA IN PRODUCTIE: SQL injection, auth gaps, race conditions, data integrity
|
||||
3. DOCUMENTATIE: CLAUDE.md actualizat, docs/ la zi, memory/ updatat
|
||||
|
||||
Citeste CLAUDE.md si MEMORY.md inainte de orice.
|
||||
Foloseste agenti Explore in paralel (minim 5 simultan) pentru scanare.
|
||||
|
||||
Grupeaza gasirile in: CRITICAL / IMPORTANT / NICE-TO-HAVE
|
||||
NU modifica cod fara sa listezi mai intai toate schimbarile propuse.
|
||||
npx next build TREBUIE sa treaca dupa fiecare schimbare.
|
||||
Commit frecvent cu mesaje descriptive.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "docs",
|
||||
label: "Documentatie & Meta",
|
||||
icon: <FileText className="size-4" />,
|
||||
description: "Update documentatie, CLAUDE.md, memory, si meta-prompting",
|
||||
prompts: [
|
||||
{
|
||||
id: "update-claudemd",
|
||||
title: "Actualizeaza CLAUDE.md",
|
||||
description: "Sincronizeaza CLAUDE.md cu starea actuala a codului",
|
||||
tags: ["periodic", "docs"],
|
||||
prompt: `CLAUDE.md trebuie actualizat sa reflecte starea curenta a proiectului.
|
||||
|
||||
Pasi:
|
||||
1. Citeste CLAUDE.md curent
|
||||
2. Verifica fiecare sectiune contra codului real:
|
||||
- Module table: sunt toate modulele? Versiuni corecte?
|
||||
- Stack: versiuni la zi?
|
||||
- Conventions: se respecta?
|
||||
- Common Pitfalls: mai sunt relevante? Lipsesc altele noi?
|
||||
- Infrastructure: porturi/servicii corecte?
|
||||
3. Citeste docs/MODULE-MAP.md — e la zi?
|
||||
4. Citeste docs/ARCHITECTURE-QUICK.md — e la zi?
|
||||
|
||||
Propune schimbarile necesare inainte de a le aplica.
|
||||
Target: CLAUDE.md sub 200 linii, informatii derivabile din cod mutate in docs/.`,
|
||||
},
|
||||
{
|
||||
id: "update-memory",
|
||||
title: "Actualizeaza memory/",
|
||||
description: "Curata memorii vechi si adauga context nou",
|
||||
tags: ["periodic", "meta"],
|
||||
prompt: `Verifica si actualizeaza memory/ files.
|
||||
|
||||
Citeste memory/MEMORY.md si fiecare fisier indexat.
|
||||
|
||||
Pentru fiecare memorie:
|
||||
1. E inca relevanta? Daca nu, sterge-o.
|
||||
2. Informatia e la zi? Daca nu, actualizeaz-o.
|
||||
3. Informatia e derivabila din cod? Daca da, sterge-o (redundanta).
|
||||
|
||||
Adauga memorii NOI pentru:
|
||||
- Decizii arhitecturale recente care nu sunt in CLAUDE.md
|
||||
- Feedback-ul meu din aceasta sesiune (daca am corectat ceva)
|
||||
- Starea task-urilor in progress
|
||||
|
||||
NU salva in memory: cod, structura fisierelor, git history — astea se pot citi direct.`,
|
||||
},
|
||||
{
|
||||
id: "improve-prompts",
|
||||
title: "Imbunatateste prompturile",
|
||||
description: "Meta-prompt: analizeaza si rafineaza prompturile din aceasta pagina",
|
||||
tags: ["periodic", "meta"],
|
||||
prompt: `Citeste codul paginii /prompts (src/app/(modules)/prompts/page.tsx).
|
||||
|
||||
Analizeaza fiecare prompt din CATEGORIES:
|
||||
1. E clar si specific? Lipseste context?
|
||||
2. Pasii sunt in ordine logica?
|
||||
3. Include safety nets (build check, aprobare)?
|
||||
4. E prea lung/scurt?
|
||||
5. Variabilele sunt utile?
|
||||
|
||||
Apoi gandeste: ce prompturi noi ar fi utile bazat pe:
|
||||
- Tipurile de task-uri care apar frecvent in git log
|
||||
- Module care sunt modificate des
|
||||
- Greseli care se repeta (din memory/ feedback)
|
||||
|
||||
Propune imbunatatiri si prompturi noi. Aplica dupa aprobare.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "quick",
|
||||
label: "Quick Actions",
|
||||
icon: <Zap className="size-4" />,
|
||||
description: "Prompturi scurte pentru actiuni rapide si frecvente",
|
||||
prompts: [
|
||||
{
|
||||
id: "quick-build",
|
||||
title: "Verifica build",
|
||||
description: "Build check rapid",
|
||||
tags: ["quick"],
|
||||
prompt: `Ruleaza npx next build si raporteaza rezultatul. Daca sunt erori, propune fix-uri.`,
|
||||
},
|
||||
{
|
||||
id: "deploy-prep",
|
||||
title: "Pregatire deploy",
|
||||
description: "Checklist complet inainte de push la productie",
|
||||
tags: ["regular", "deploy"],
|
||||
prompt: `Pregateste deploy-ul pe productie (tools.beletage.ro).
|
||||
|
||||
Checklist:
|
||||
1. git status — totul comis? Fisiere untracked suspecte?
|
||||
2. npx next build — zero erori?
|
||||
3. docker-compose.yml — env vars noi necesare?
|
||||
4. prisma/schema.prisma — s-a schimbat? Necesita migrate?
|
||||
5. middleware.ts — rute noi excluse daca e cazul?
|
||||
6. Verifica ca nu sunt credentials hardcoded in cod
|
||||
7. git log --oneline -5 — commit messages descriptive?
|
||||
|
||||
Daca totul e ok, confirma "Ready to push".
|
||||
Daca sunt probleme, listeaza-le cu fix propus.
|
||||
|
||||
IMPORTANT: Dupa push, deploy-ul e MANUAL in Portainer.
|
||||
Daca schema Prisma s-a schimbat, trebuie migrate pe server.`,
|
||||
},
|
||||
{
|
||||
id: "debug-unknown",
|
||||
title: "Debug eroare necunoscuta",
|
||||
description: "Investigheaza o eroare fara cauza evidenta",
|
||||
tags: ["regular", "debug"],
|
||||
variables: ["ERROR_DESCRIPTION"],
|
||||
prompt: `Am o eroare: {ERROR_DESCRIPTION}
|
||||
|
||||
Investigheaza:
|
||||
1. Citeste stack trace-ul (daca exista) — gaseste fisierul root cause
|
||||
2. Citeste codul relevant — nu ghici, verifica
|
||||
3. Cauta pattern-uri similare in codebase (grep)
|
||||
4. Verifica git log recent — s-a schimbat ceva care ar cauza asta?
|
||||
5. Verifica env vars — lipseste ceva?
|
||||
6. Verifica Prisma schema — model-ul e in sync?
|
||||
|
||||
Dupa investigatie:
|
||||
- Explica cauza root (nu simptomul)
|
||||
- Propune fix minim
|
||||
- Aplica fix dupa aprobare
|
||||
- npx next build TREBUIE sa treaca`,
|
||||
},
|
||||
{
|
||||
id: "quick-deps",
|
||||
title: "Update dependinte",
|
||||
description: "Verifica si actualizeaza package.json",
|
||||
tags: ["quick"],
|
||||
prompt: `Verifica daca sunt update-uri disponibile pentru dependintele din package.json.
|
||||
|
||||
Ruleaza: npm outdated
|
||||
Listeaza ce se poate actualiza safe (minor/patch).
|
||||
NU actualiza major versions fara discutie.
|
||||
Dupa update: npx next build TREBUIE sa treaca.`,
|
||||
},
|
||||
{
|
||||
id: "quick-git-cleanup",
|
||||
title: "Git cleanup",
|
||||
description: "Verifica starea repo-ului si curata",
|
||||
tags: ["quick"],
|
||||
prompt: `Verifica starea repo-ului:
|
||||
1. git status — fisiere uncommited?
|
||||
2. git log --oneline -10 — commit-uri recente ok?
|
||||
3. Fisiere untracked suspecte? (.env, tmp files, build artifacts)
|
||||
4. .gitignore — lipseste ceva?
|
||||
|
||||
Propune cleanup daca e nevoie.`,
|
||||
},
|
||||
{
|
||||
id: "quick-type-check",
|
||||
title: "Verificare tipuri modul",
|
||||
description: "Verifica typesafety pe un modul specific",
|
||||
tags: ["quick"],
|
||||
variables: ["MODULE_NAME"],
|
||||
prompt: `Citeste src/modules/{MODULE_NAME}/types.ts si verifica:
|
||||
1. Toate interfetele sunt folosite? (grep imports)
|
||||
2. Sunt tipuri any sau unknown neutipizate?
|
||||
3. Optional fields corect marcate cu ?
|
||||
4. Consistenta cu Prisma schema (daca modulul foloseste DB direct)
|
||||
Raporteaza rapid ce nu e in regula.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Copy button component ─────────────────────────────────────────
|
||||
|
||||
function CopyButton({ text, className }: { text: string; className?: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={handleCopy}
|
||||
className={className}
|
||||
title="Copiaza"
|
||||
>
|
||||
{copied ? <Check className="size-3 text-green-500" /> : <Copy className="size-3" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Prompt Card ───────────────────────────────────────────────────
|
||||
|
||||
function PromptCard({ prompt }: { prompt: PromptTemplate }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
// Persist one-time completion in localStorage
|
||||
useEffect(() => {
|
||||
if (prompt.oneTime) {
|
||||
const stored = localStorage.getItem(`prompt-done-${prompt.id}`);
|
||||
if (stored === "true") setDone(true);
|
||||
}
|
||||
}, [prompt.id, prompt.oneTime]);
|
||||
|
||||
const toggleDone = useCallback(() => {
|
||||
const next = !done;
|
||||
setDone(next);
|
||||
localStorage.setItem(`prompt-done-${prompt.id}`, String(next));
|
||||
}, [done, prompt.id]);
|
||||
|
||||
return (
|
||||
<Card className={`transition-all ${done ? "opacity-50" : ""}`}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{prompt.oneTime && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={done}
|
||||
onChange={toggleDone}
|
||||
className="size-4 rounded accent-primary cursor-pointer"
|
||||
title={done ? "Marcheaza ca nefacut" : "Marcheaza ca facut"}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center gap-1 text-sm font-semibold hover:text-primary transition-colors text-left"
|
||||
>
|
||||
{expanded ? <ChevronDown className="size-3.5 shrink-0" /> : <ChevronRight className="size-3.5 shrink-0" />}
|
||||
{prompt.title}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground ml-5">{prompt.description}</p>
|
||||
<div className="flex flex-wrap gap-1 mt-2 ml-5">
|
||||
{prompt.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{prompt.variables?.map((v) => (
|
||||
<Badge key={v} variant="outline" className="text-[10px] px-1.5 py-0 border-amber-500/50 text-amber-600 dark:text-amber-400">
|
||||
{`{${v}}`}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<CopyButton text={prompt.prompt} className="shrink-0 mt-0.5" />
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="mt-3 ml-5">
|
||||
<div className="relative group">
|
||||
<pre className="text-xs bg-muted/50 border rounded-md p-3 whitespace-pre-wrap font-mono leading-relaxed overflow-x-auto max-h-[500px] overflow-y-auto">
|
||||
{prompt.prompt}
|
||||
</pre>
|
||||
<CopyButton
|
||||
text={prompt.prompt}
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity bg-background/80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Stats bar ─────────────────────────────────────────────────────
|
||||
|
||||
function StatsBar() {
|
||||
const totalPrompts = CATEGORIES.reduce((s, c) => s + c.prompts.length, 0);
|
||||
const oneTimePrompts = CATEGORIES.reduce(
|
||||
(s, c) => s + c.prompts.filter((p) => p.oneTime).length,
|
||||
0,
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1"><Brain className="size-3" /> {totalPrompts} prompturi</span>
|
||||
<span className="flex items-center gap-1"><ListChecks className="size-3" /> {oneTimePrompts} one-time</span>
|
||||
<span className="flex items-center gap-1"><Wrench className="size-3" /> {CATEGORIES.length} categorii</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Best practices sidebar ────────────────────────────────────────
|
||||
|
||||
const BEST_PRACTICES = [
|
||||
{ icon: <Rocket className="size-3" />, text: "Incepe cu CLAUDE.md — da context inainte de task" },
|
||||
{ icon: <Brain className="size-3" />, text: "Cere plan inainte de implementare" },
|
||||
{ icon: <Shield className="size-3" />, text: "npx next build dupa fiecare schimbare" },
|
||||
{ icon: <Bug className="size-3" />, text: "Nu refactoriza cand faci bugfix" },
|
||||
{ icon: <Sparkles className="size-3" />, text: "O sesiune = un obiectiv clar" },
|
||||
{ icon: <TestTube className="size-3" />, text: "Verifica-ti munca: teste, build, manual" },
|
||||
{ icon: <ListChecks className="size-3" />, text: "Listeaza schimbarile inainte de a le aplica" },
|
||||
{ icon: <RefreshCw className="size-3" />, text: "Actualizeaza memory/ la sfarsit de sesiune" },
|
||||
{ icon: <Zap className="size-3" />, text: "/clear intre task-uri diferite" },
|
||||
{ icon: <Terminal className="size-3" />, text: "Dupa 2 corectii → /clear + prompt mai bun" },
|
||||
];
|
||||
|
||||
// ─── Main Page ─────────────────────────────────────────────────────
|
||||
|
||||
export default function PromptsPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filtered = CATEGORIES.map((cat) => ({
|
||||
...cat,
|
||||
prompts: cat.prompts.filter(
|
||||
(p) =>
|
||||
!search ||
|
||||
p.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
p.description.toLowerCase().includes(search.toLowerCase()) ||
|
||||
p.tags.some((t) => t.toLowerCase().includes(search.toLowerCase())),
|
||||
),
|
||||
})).filter((cat) => cat.prompts.length > 0);
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Terminal className="size-6" />
|
||||
Claude Code Prompts
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Biblioteca de prompturi optimizate pentru ArchiTools. Click pe titlu pentru a vedea, buton pentru a copia.
|
||||
</p>
|
||||
<StatsBar />
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Cauta prompt... (ex: bugfix, security, parcel-sync)"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1 h-9 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
{search && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setSearch("")}>
|
||||
Sterge
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_240px] gap-6">
|
||||
{/* Main content */}
|
||||
<Tabs defaultValue={CATEGORIES[0]?.id} className="w-full">
|
||||
<TabsList className="w-full flex flex-wrap h-auto gap-1 bg-transparent p-0 mb-4">
|
||||
{filtered.map((cat) => (
|
||||
<TabsTrigger
|
||||
key={cat.id}
|
||||
value={cat.id}
|
||||
className="flex items-center gap-1.5 text-xs data-[state=active]:bg-primary data-[state=active]:text-primary-foreground rounded-md px-3 py-1.5 border"
|
||||
>
|
||||
{cat.icon}
|
||||
{cat.label}
|
||||
<Badge variant="secondary" className="text-[10px] px-1 py-0 ml-1">
|
||||
{cat.prompts.length}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{filtered.map((cat) => (
|
||||
<TabsContent key={cat.id} value={cat.id} className="space-y-3 mt-0">
|
||||
<p className="text-xs text-muted-foreground mb-3">{cat.description}</p>
|
||||
{cat.prompts.map((prompt) => (
|
||||
<PromptCard key={prompt.id} prompt={prompt} />
|
||||
))}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<h3 className="text-sm font-semibold mb-3 flex items-center gap-1.5">
|
||||
<Sparkles className="size-3.5" />
|
||||
Best Practices
|
||||
</h3>
|
||||
<div className="space-y-2.5">
|
||||
{BEST_PRACTICES.map((bp, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||
<span className="mt-0.5 shrink-0">{bp.icon}</span>
|
||||
<span>{bp.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<h3 className="text-sm font-semibold mb-3 flex items-center gap-1.5">
|
||||
<Terminal className="size-3.5" />
|
||||
Module disponibile
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{MODULES.map((m) => (
|
||||
<Badge key={m} variant="outline" className="text-[10px] px-1.5 py-0 cursor-pointer hover:bg-accent" onClick={() => {
|
||||
navigator.clipboard.writeText(m);
|
||||
}}>
|
||||
{m}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-2">Click pe modul = copiaza numele</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,847 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/shared/components/ui/tabs";
|
||||
|
||||
/* ─── Types ──────────────────────────────────────────────── */
|
||||
|
||||
type SyncRule = {
|
||||
id: string;
|
||||
siruta: string | null;
|
||||
county: string | null;
|
||||
frequency: string;
|
||||
syncTerenuri: boolean;
|
||||
syncCladiri: boolean;
|
||||
syncNoGeom: boolean;
|
||||
syncEnrich: boolean;
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
allowedHoursStart: number | null;
|
||||
allowedHoursEnd: number | null;
|
||||
allowedDays: string | null;
|
||||
lastSyncAt: string | null;
|
||||
lastSyncStatus: string | null;
|
||||
lastSyncError: string | null;
|
||||
nextDueAt: string | null;
|
||||
label: string | null;
|
||||
createdAt: string;
|
||||
// enriched
|
||||
uatName: string | null;
|
||||
uatCount: number;
|
||||
};
|
||||
|
||||
type SchedulerStats = {
|
||||
totalRules: number;
|
||||
activeRules: number;
|
||||
dueNow: number;
|
||||
withErrors: number;
|
||||
frequencyDistribution: Record<string, number>;
|
||||
totalCounties: number;
|
||||
countiesWithRules: number;
|
||||
};
|
||||
|
||||
type CountyOverview = {
|
||||
county: string;
|
||||
totalUats: number;
|
||||
withRules: number;
|
||||
defaultFreq: string | null;
|
||||
};
|
||||
|
||||
/* ─── Constants ──────────────────────────────────────────── */
|
||||
|
||||
const FREQ_LABELS: Record<string, string> = {
|
||||
"3x-daily": "3x/zi",
|
||||
daily: "Zilnic",
|
||||
weekly: "Saptamanal",
|
||||
monthly: "Lunar",
|
||||
manual: "Manual",
|
||||
};
|
||||
|
||||
const FREQ_COLORS: Record<string, string> = {
|
||||
"3x-daily": "bg-red-500/20 text-red-400",
|
||||
daily: "bg-orange-500/20 text-orange-400",
|
||||
weekly: "bg-blue-500/20 text-blue-400",
|
||||
monthly: "bg-gray-500/20 text-gray-400",
|
||||
manual: "bg-purple-500/20 text-purple-400",
|
||||
};
|
||||
|
||||
/* ─── Page ───────────────────────────────────────────────── */
|
||||
|
||||
export default function SyncManagementPage() {
|
||||
const [rules, setRules] = useState<SyncRule[]>([]);
|
||||
const [globalDefault, setGlobalDefault] = useState("monthly");
|
||||
const [stats, setStats] = useState<SchedulerStats | null>(null);
|
||||
const [counties, setCounties] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
const [filterCounty, setFilterCounty] = useState("");
|
||||
const [filterFreq, setFilterFreq] = useState("");
|
||||
|
||||
const fetchRules = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/eterra/sync-rules");
|
||||
if (res.ok) {
|
||||
const d = (await res.json()) as { rules: SyncRule[]; globalDefault: string };
|
||||
setRules(d.rules);
|
||||
setGlobalDefault(d.globalDefault);
|
||||
}
|
||||
} catch { /* noop */ }
|
||||
}, []);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/eterra/sync-rules/scheduler");
|
||||
if (res.ok) {
|
||||
const d = (await res.json()) as { stats: SchedulerStats };
|
||||
setStats(d.stats);
|
||||
}
|
||||
} catch { /* noop */ }
|
||||
}, []);
|
||||
|
||||
const fetchCounties = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/eterra/counties");
|
||||
if (res.ok) {
|
||||
const d = (await res.json()) as { counties: string[] };
|
||||
setCounties(d.counties ?? []);
|
||||
}
|
||||
} catch { /* noop */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void Promise.all([fetchRules(), fetchStats(), fetchCounties()]).then(() =>
|
||||
setLoading(false),
|
||||
);
|
||||
}, [fetchRules, fetchStats, fetchCounties]);
|
||||
|
||||
// Auto-refresh every 30s
|
||||
useEffect(() => {
|
||||
const iv = setInterval(() => {
|
||||
void fetchRules();
|
||||
void fetchStats();
|
||||
}, 30_000);
|
||||
return () => clearInterval(iv);
|
||||
}, [fetchRules, fetchStats]);
|
||||
|
||||
const toggleEnabled = async (rule: SyncRule) => {
|
||||
await fetch(`/api/eterra/sync-rules/${rule.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: !rule.enabled }),
|
||||
});
|
||||
void fetchRules();
|
||||
};
|
||||
|
||||
const deleteRule = async (rule: SyncRule) => {
|
||||
await fetch(`/api/eterra/sync-rules/${rule.id}`, { method: "DELETE" });
|
||||
void fetchRules();
|
||||
void fetchStats();
|
||||
};
|
||||
|
||||
const updateGlobalDefault = async (freq: string) => {
|
||||
await fetch("/api/eterra/sync-rules/global-default", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ frequency: freq }),
|
||||
});
|
||||
setGlobalDefault(freq);
|
||||
};
|
||||
|
||||
const filteredRules = rules.filter((r) => {
|
||||
if (filterCounty && r.county !== filterCounty && r.siruta) {
|
||||
// For UAT rules, need to check if UAT is in filtered county — skip for now, show all UAT rules when county filter is set
|
||||
return false;
|
||||
}
|
||||
if (filterCounty && r.county && r.county !== filterCounty) return false;
|
||||
if (filterFreq && r.frequency !== filterFreq) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Build county overview from stats
|
||||
const countyOverview: CountyOverview[] = counties.map((c) => {
|
||||
const countyRule = rules.find((r) => r.county === c && !r.siruta);
|
||||
const uatRules = rules.filter((r) => r.county === null && r.siruta !== null);
|
||||
return {
|
||||
county: c,
|
||||
totalUats: 0, // filled by separate query if needed
|
||||
withRules: (countyRule ? 1 : 0) + uatRules.length,
|
||||
defaultFreq: countyRule?.frequency ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl p-6">
|
||||
<h1 className="text-2xl font-bold mb-4">Sync Management</h1>
|
||||
<div className="h-64 rounded-lg bg-muted/50 animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Sync Management</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Reguli de sincronizare eTerra — {rules.length} reguli configurate
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/monitor"
|
||||
className="px-4 py-2 rounded border border-border text-sm hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
Monitor
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Global Default */}
|
||||
<div className="rounded-lg border border-border bg-card p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-medium">Frecventa implicita (UAT-uri fara regula)</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Se aplica la UAT-urile care nu au regula specifica si nici regula de judet
|
||||
</p>
|
||||
</div>
|
||||
<select
|
||||
value={globalDefault}
|
||||
onChange={(e) => void updateGlobalDefault(e.target.value)}
|
||||
className="h-9 rounded-md border border-border bg-background px-3 text-sm"
|
||||
>
|
||||
{Object.entries(FREQ_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="rules">
|
||||
<TabsList>
|
||||
<TabsTrigger value="rules">Reguli ({rules.length})</TabsTrigger>
|
||||
<TabsTrigger value="status">Status</TabsTrigger>
|
||||
<TabsTrigger value="counties">Judete ({counties.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ═══ RULES TAB ═══ */}
|
||||
<TabsContent value="rules" className="space-y-4 mt-4">
|
||||
{/* Filters + Add button */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<select
|
||||
value={filterCounty}
|
||||
onChange={(e) => setFilterCounty(e.target.value)}
|
||||
className="h-9 w-48 rounded-md border border-border bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="">Toate judetele</option>
|
||||
{counties.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={filterFreq}
|
||||
onChange={(e) => setFilterFreq(e.target.value)}
|
||||
className="h-9 w-40 rounded-md border border-border bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="">Toate frecventele</option>
|
||||
{Object.entries(FREQ_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="ml-auto">
|
||||
<button
|
||||
onClick={() => setShowAddDialog(true)}
|
||||
className="h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm hover:bg-primary/90"
|
||||
>
|
||||
Adauga regula
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rules table */}
|
||||
{filteredRules.length === 0 ? (
|
||||
<div className="rounded-lg border border-border bg-card p-8 text-center text-muted-foreground">
|
||||
Nicio regula {filterCounty || filterFreq ? "pentru filtrul selectat" : "configurata"}. Apasa "Adauga regula" pentru a incepe.
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/30">
|
||||
<th className="text-left py-2.5 px-3 font-medium">Scope</th>
|
||||
<th className="text-left py-2.5 px-3 font-medium">Frecventa</th>
|
||||
<th className="text-left py-2.5 px-3 font-medium">Pasi</th>
|
||||
<th className="text-left py-2.5 px-3 font-medium">Prioritate</th>
|
||||
<th className="text-left py-2.5 px-3 font-medium">Ultimul sync</th>
|
||||
<th className="text-left py-2.5 px-3 font-medium">Urmatorul</th>
|
||||
<th className="text-center py-2.5 px-3 font-medium">Activ</th>
|
||||
<th className="text-right py-2.5 px-3 font-medium">Actiuni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRules.map((r) => (
|
||||
<RuleRow
|
||||
key={r.id}
|
||||
rule={r}
|
||||
onToggle={() => void toggleEnabled(r)}
|
||||
onDelete={() => void deleteRule(r)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ═══ STATUS TAB ═══ */}
|
||||
<TabsContent value="status" className="space-y-4 mt-4">
|
||||
{stats && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard label="Total reguli" value={stats.totalRules} />
|
||||
<StatCard label="Active" value={stats.activeRules} />
|
||||
<StatCard
|
||||
label="Scadente acum"
|
||||
value={stats.dueNow}
|
||||
highlight={stats.dueNow > 0}
|
||||
/>
|
||||
<StatCard
|
||||
label="Cu erori"
|
||||
value={stats.withErrors}
|
||||
highlight={stats.withErrors > 0}
|
||||
error
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">
|
||||
Distributie frecvente
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{Object.entries(FREQ_LABELS).map(([key, label]) => {
|
||||
const count = stats.frequencyDistribution[key] ?? 0;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-md border border-border"
|
||||
>
|
||||
<FreqBadge freq={key} />
|
||||
<span className="text-sm font-medium">{count}</span>
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">
|
||||
Acoperire
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Judete cu reguli:</span>{" "}
|
||||
<span className="font-medium">{stats.countiesWithRules} / {stats.totalCounties}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Default global:</span>{" "}
|
||||
<FreqBadge freq={globalDefault} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overdue rules */}
|
||||
{stats.dueNow > 0 && (
|
||||
<div className="rounded-lg border border-yellow-500/30 bg-yellow-500/5 p-4">
|
||||
<h3 className="text-sm font-semibold text-yellow-400 mb-2">
|
||||
Reguli scadente ({stats.dueNow})
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Scheduler-ul va procesa aceste reguli la urmatorul tick.
|
||||
(Scheduler-ul unificat va fi activat in Phase 2)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ═══ COUNTIES TAB ═══ */}
|
||||
<TabsContent value="counties" className="space-y-4 mt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Seteaza frecventa de sync la nivel de judet. UAT-urile cu regula proprie o vor suprascrie.
|
||||
</p>
|
||||
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/30">
|
||||
<th className="text-left py-2.5 px-3 font-medium">Judet</th>
|
||||
<th className="text-left py-2.5 px-3 font-medium">Frecventa curenta</th>
|
||||
<th className="text-right py-2.5 px-3 font-medium">Seteaza frecventa</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{counties.map((c) => (
|
||||
<CountyRow
|
||||
key={c}
|
||||
county={c}
|
||||
currentFreq={countyOverview.find((o) => o.county === c)?.defaultFreq ?? null}
|
||||
globalDefault={globalDefault}
|
||||
onSetFreq={async (freq) => {
|
||||
await fetch("/api/eterra/sync-rules/bulk", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "set-county-frequency",
|
||||
county: c,
|
||||
frequency: freq,
|
||||
}),
|
||||
});
|
||||
void fetchRules();
|
||||
void fetchStats();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Add Rule Dialog */}
|
||||
{showAddDialog && (
|
||||
<AddRuleDialog
|
||||
counties={counties}
|
||||
onClose={() => setShowAddDialog(false)}
|
||||
onCreated={() => {
|
||||
setShowAddDialog(false);
|
||||
void fetchRules();
|
||||
void fetchStats();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Sub-components ─────────────────────────────────────── */
|
||||
|
||||
function FreqBadge({ freq }: { freq: string }) {
|
||||
return (
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${FREQ_COLORS[freq] ?? "bg-muted text-muted-foreground"}`}>
|
||||
{FREQ_LABELS[freq] ?? freq}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, highlight, error }: {
|
||||
label: string; value: number; highlight?: boolean; error?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={`rounded-lg border p-4 ${
|
||||
highlight
|
||||
? error ? "border-red-500/30 bg-red-500/5" : "border-yellow-500/30 bg-yellow-500/5"
|
||||
: "border-border bg-card"
|
||||
}`}>
|
||||
<div className="text-xs text-muted-foreground mb-1">{label}</div>
|
||||
<div className={`text-2xl font-bold tabular-nums ${
|
||||
highlight ? (error ? "text-red-400" : "text-yellow-400") : ""
|
||||
}`}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RuleRow({ rule, onToggle, onDelete }: {
|
||||
rule: SyncRule; onToggle: () => void; onDelete: () => void;
|
||||
}) {
|
||||
const scope = rule.siruta
|
||||
? (rule.uatName ?? rule.siruta)
|
||||
: rule.county
|
||||
? `Judet: ${rule.county}`
|
||||
: "Global";
|
||||
|
||||
const scopeSub = rule.siruta
|
||||
? `SIRUTA ${rule.siruta}`
|
||||
: rule.uatCount > 0
|
||||
? `${rule.uatCount} UAT-uri`
|
||||
: null;
|
||||
|
||||
const isOverdue = rule.nextDueAt && new Date(rule.nextDueAt) < new Date();
|
||||
|
||||
return (
|
||||
<tr className={`border-b border-border/50 ${!rule.enabled ? "opacity-50" : ""}`}>
|
||||
<td className="py-2.5 px-3">
|
||||
<div className="font-medium">{scope}</div>
|
||||
{scopeSub && <div className="text-xs text-muted-foreground">{scopeSub}</div>}
|
||||
{rule.label && <div className="text-xs text-blue-400 mt-0.5">{rule.label}</div>}
|
||||
</td>
|
||||
<td className="py-2.5 px-3">
|
||||
<FreqBadge freq={rule.frequency} />
|
||||
</td>
|
||||
<td className="py-2.5 px-3">
|
||||
<div className="flex gap-1">
|
||||
{rule.syncTerenuri && <StepIcon label="T" title="Terenuri" />}
|
||||
{rule.syncCladiri && <StepIcon label="C" title="Cladiri" />}
|
||||
{rule.syncNoGeom && <StepIcon label="N" title="No-geom" />}
|
||||
{rule.syncEnrich && <StepIcon label="E" title="Enrichment" />}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2.5 px-3 tabular-nums">{rule.priority}</td>
|
||||
<td className="py-2.5 px-3">
|
||||
{rule.lastSyncAt ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`w-2 h-2 rounded-full shrink-0 ${
|
||||
rule.lastSyncStatus === "done" ? "bg-green-400" :
|
||||
rule.lastSyncStatus === "error" ? "bg-red-400" : "bg-gray-400"
|
||||
}`} />
|
||||
<span className="text-xs">{relativeTime(rule.lastSyncAt)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Niciodata</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 px-3">
|
||||
{rule.nextDueAt ? (
|
||||
<span className={`text-xs ${isOverdue ? "text-yellow-400 font-medium" : "text-muted-foreground"}`}>
|
||||
{isOverdue ? "Scadent" : relativeTime(rule.nextDueAt)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-center">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`w-8 h-5 rounded-full transition-colors relative ${
|
||||
rule.enabled ? "bg-green-500" : "bg-muted"
|
||||
}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
|
||||
rule.enabled ? "left-3.5" : "left-0.5"
|
||||
}`} />
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-right">
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="text-xs text-muted-foreground hover:text-red-400 transition-colors"
|
||||
>
|
||||
Sterge
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function StepIcon({ label, title }: { label: string; title: string }) {
|
||||
return (
|
||||
<span
|
||||
title={title}
|
||||
className="w-5 h-5 rounded text-[10px] font-bold flex items-center justify-center bg-muted text-muted-foreground"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CountyRow({ county, currentFreq, globalDefault, onSetFreq }: {
|
||||
county: string;
|
||||
currentFreq: string | null;
|
||||
globalDefault: string;
|
||||
onSetFreq: (freq: string) => Promise<void>;
|
||||
}) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
return (
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="py-2.5 px-3 font-medium">{county}</td>
|
||||
<td className="py-2.5 px-3">
|
||||
{currentFreq ? (
|
||||
<FreqBadge freq={currentFreq} />
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Implicit ({FREQ_LABELS[globalDefault] ?? globalDefault})
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-right">
|
||||
<select
|
||||
value={currentFreq ?? ""}
|
||||
disabled={saving}
|
||||
onChange={async (e) => {
|
||||
if (!e.target.value) return;
|
||||
setSaving(true);
|
||||
await onSetFreq(e.target.value);
|
||||
setSaving(false);
|
||||
}}
|
||||
className="h-8 rounded-md border border-border bg-background px-2 text-xs"
|
||||
>
|
||||
<option value="">Alege...</option>
|
||||
{Object.entries(FREQ_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function AddRuleDialog({ counties, onClose, onCreated }: {
|
||||
counties: string[];
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const [ruleType, setRuleType] = useState<"uat" | "county">("county");
|
||||
const [siruta, setSiruta] = useState("");
|
||||
const [county, setCounty] = useState("");
|
||||
const [frequency, setFrequency] = useState("daily");
|
||||
const [syncEnrich, setSyncEnrich] = useState(false);
|
||||
const [syncNoGeom, setSyncNoGeom] = useState(false);
|
||||
const [priority, setPriority] = useState(5);
|
||||
const [label, setLabel] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// UAT search — load all once, filter client-side
|
||||
const [uatSearch, setUatSearch] = useState("");
|
||||
const [allUats, setAllUats] = useState<Array<{ siruta: string; name: string }>>([]);
|
||||
const [uatName, setUatName] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch("/api/eterra/uats");
|
||||
if (res.ok) {
|
||||
const d = (await res.json()) as { uats?: Array<{ siruta: string; name: string }> };
|
||||
setAllUats(d.uats ?? []);
|
||||
}
|
||||
} catch { /* noop */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const uatResults = uatSearch.length >= 2
|
||||
? allUats
|
||||
.filter((u) => {
|
||||
const q = uatSearch.toLowerCase();
|
||||
return u.name.toLowerCase().includes(q) || u.siruta.includes(q);
|
||||
})
|
||||
.slice(0, 10)
|
||||
: [];
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError("");
|
||||
setSaving(true);
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
frequency,
|
||||
syncEnrich,
|
||||
syncNoGeom,
|
||||
priority,
|
||||
label: label.trim() || null,
|
||||
};
|
||||
|
||||
if (ruleType === "uat") {
|
||||
if (!siruta) { setError("Selecteaza un UAT"); setSaving(false); return; }
|
||||
body.siruta = siruta;
|
||||
} else {
|
||||
if (!county) { setError("Selecteaza un judet"); setSaving(false); return; }
|
||||
body.county = county;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/eterra/sync-rules", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const d = (await res.json()) as { rule?: SyncRule; error?: string };
|
||||
if (!res.ok) {
|
||||
setError(d.error ?? "Eroare");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
onCreated();
|
||||
} catch {
|
||||
setError("Eroare retea");
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div
|
||||
className="bg-card border border-border rounded-lg p-6 w-full max-w-md space-y-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-lg font-semibold">Adauga regula de sync</h2>
|
||||
|
||||
{/* Rule type toggle */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setRuleType("county")}
|
||||
className={`flex-1 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
ruleType === "county" ? "bg-primary text-primary-foreground" : "bg-muted"
|
||||
}`}
|
||||
>
|
||||
Judet
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRuleType("uat")}
|
||||
className={`flex-1 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
ruleType === "uat" ? "bg-primary text-primary-foreground" : "bg-muted"
|
||||
}`}
|
||||
>
|
||||
UAT specific
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scope selection */}
|
||||
{ruleType === "county" ? (
|
||||
<select
|
||||
value={county}
|
||||
onChange={(e) => setCounty(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-border bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="">Alege judet...</option>
|
||||
{counties.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={uatSearch}
|
||||
onChange={(e) => { setUatSearch(e.target.value); setSiruta(""); setUatName(""); }}
|
||||
placeholder="Cauta UAT (nume sau SIRUTA)..."
|
||||
className="h-9 w-full rounded-md border border-border bg-background px-3 text-sm"
|
||||
/>
|
||||
{uatName && (
|
||||
<div className="text-xs text-green-400 mt-1">
|
||||
Selectat: {uatName} ({siruta})
|
||||
</div>
|
||||
)}
|
||||
{uatResults.length > 0 && !siruta && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-card border border-border rounded-md shadow-lg max-h-40 overflow-y-auto z-10">
|
||||
{uatResults.map((u) => (
|
||||
<button
|
||||
key={u.siruta}
|
||||
onClick={() => {
|
||||
setSiruta(u.siruta);
|
||||
setUatName(u.name);
|
||||
setUatSearch("");
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
{u.name} <span className="text-muted-foreground">({u.siruta})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Frequency */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">Frecventa</label>
|
||||
<select
|
||||
value={frequency}
|
||||
onChange={(e) => setFrequency(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-border bg-background px-3 text-sm"
|
||||
>
|
||||
{Object.entries(FREQ_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Sync steps */}
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={syncEnrich}
|
||||
onChange={(e) => setSyncEnrich(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Enrichment
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={syncNoGeom}
|
||||
onChange={(e) => setSyncNoGeom(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
No-geom parcels
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
Prioritate (1=cea mai mare, 10=cea mai mica)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(Number(e.target.value))}
|
||||
className="h-9 w-20 rounded-md border border-border bg-background px-3 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="Nota (optional)"
|
||||
className="h-9 w-full rounded-md border border-border bg-background px-3 text-sm"
|
||||
/>
|
||||
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-md border border-border text-sm hover:bg-muted/50"
|
||||
>
|
||||
Anuleaza
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Se salveaza..." : "Creeaza"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Helpers ────────────────────────────────────────────── */
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const abs = Math.abs(diff);
|
||||
const future = diff < 0;
|
||||
const s = Math.floor(abs / 1000);
|
||||
const m = Math.floor(s / 60);
|
||||
const h = Math.floor(m / 60);
|
||||
const d = Math.floor(h / 24);
|
||||
|
||||
let str: string;
|
||||
if (d > 0) str = `${d}z`;
|
||||
else if (h > 0) str = `${h}h`;
|
||||
else if (m > 0) str = `${m}m`;
|
||||
else str = `${s}s`;
|
||||
|
||||
return future ? `in ${str}` : `acum ${str}`;
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useDeferredValue, useRef } from "react";
|
||||
import {
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Plus,
|
||||
Trash2,
|
||||
RotateCcw,
|
||||
Moon,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
MapPin,
|
||||
Search,
|
||||
AlertTriangle,
|
||||
WifiOff,
|
||||
Activity,
|
||||
Play,
|
||||
Download,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type StepName = "sync_terenuri" | "sync_cladiri" | "import_nogeom" | "enrich";
|
||||
type StepStatus = "pending" | "done" | "error";
|
||||
|
||||
type CityState = {
|
||||
siruta: string;
|
||||
name: string;
|
||||
county: string;
|
||||
priority: number;
|
||||
steps: Record<StepName, StepStatus>;
|
||||
lastActivity?: string;
|
||||
errorMessage?: string;
|
||||
dbStats?: {
|
||||
terenuri: number;
|
||||
cladiri: number;
|
||||
total: number;
|
||||
enriched: number;
|
||||
};
|
||||
};
|
||||
|
||||
type QueueState = {
|
||||
cities: CityState[];
|
||||
lastSessionDate?: string;
|
||||
totalSessions: number;
|
||||
completedCycles: number;
|
||||
};
|
||||
|
||||
type SyncStatus = "running" | "error" | "waiting" | "idle";
|
||||
|
||||
type CurrentActivity = {
|
||||
city: string;
|
||||
step: string;
|
||||
startedAt: string;
|
||||
} | null;
|
||||
|
||||
const STEPS: StepName[] = [
|
||||
"sync_terenuri",
|
||||
"sync_cladiri",
|
||||
"import_nogeom",
|
||||
"enrich",
|
||||
];
|
||||
|
||||
const STEP_LABELS: Record<StepName, string> = {
|
||||
sync_terenuri: "Terenuri",
|
||||
sync_cladiri: "Cladiri",
|
||||
import_nogeom: "No-geom",
|
||||
enrich: "Enrichment",
|
||||
};
|
||||
|
||||
/** Auto-poll intervals */
|
||||
const POLL_ACTIVE_MS = 15_000; // 15s when running
|
||||
const POLL_IDLE_MS = 60_000; // 60s when idle
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Page */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export default function WeekendDeepSyncPage() {
|
||||
const [state, setState] = useState<QueueState | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
// Live status
|
||||
const [syncStatus, setSyncStatus] = useState<SyncStatus>("idle");
|
||||
const [currentActivity, setCurrentActivity] = useState<CurrentActivity>(null);
|
||||
const [inWeekendWindow, setInWeekendWindow] = useState(false);
|
||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
|
||||
|
||||
// UAT autocomplete for adding cities
|
||||
type UatEntry = { siruta: string; name: string; county?: string };
|
||||
const [uatData, setUatData] = useState<UatEntry[]>([]);
|
||||
const [uatQuery, setUatQuery] = useState("");
|
||||
const [uatResults, setUatResults] = useState<UatEntry[]>([]);
|
||||
const [showUatResults, setShowUatResults] = useState(false);
|
||||
const uatRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const fetchState = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/eterra/weekend-sync");
|
||||
if (!res.ok) {
|
||||
setFetchError(`Server: ${res.status} ${res.statusText}`);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as {
|
||||
state: QueueState | null;
|
||||
syncStatus?: SyncStatus;
|
||||
currentActivity?: CurrentActivity;
|
||||
inWeekendWindow?: boolean;
|
||||
};
|
||||
setState(data.state);
|
||||
setSyncStatus(data.syncStatus ?? "idle");
|
||||
setCurrentActivity(data.currentActivity ?? null);
|
||||
setInWeekendWindow(data.inWeekendWindow ?? false);
|
||||
setFetchError(null);
|
||||
setLastRefresh(new Date());
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Conexiune esuata";
|
||||
setFetchError(msg);
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
// Initial load + UAT list
|
||||
useEffect(() => {
|
||||
void fetchState();
|
||||
fetch("/api/eterra/uats")
|
||||
.then((r) => r.json())
|
||||
.then((data: { uats?: UatEntry[] }) => {
|
||||
if (data.uats) setUatData(data.uats);
|
||||
})
|
||||
.catch(() => {
|
||||
fetch("/uat.json")
|
||||
.then((r) => r.json())
|
||||
.then((fallback: UatEntry[]) => setUatData(fallback))
|
||||
.catch(() => {});
|
||||
});
|
||||
}, [fetchState]);
|
||||
|
||||
// Auto-poll: 15s when running, 60s otherwise
|
||||
useEffect(() => {
|
||||
const interval = syncStatus === "running" ? POLL_ACTIVE_MS : POLL_IDLE_MS;
|
||||
const timer = setInterval(() => void fetchState(), interval);
|
||||
return () => clearInterval(timer);
|
||||
}, [fetchState, syncStatus]);
|
||||
|
||||
// UAT autocomplete filter
|
||||
const normalizeText = (text: string) =>
|
||||
text.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim();
|
||||
|
||||
const deferredUatQuery = useDeferredValue(uatQuery);
|
||||
useEffect(() => {
|
||||
const raw = deferredUatQuery.trim();
|
||||
if (raw.length < 2) { setUatResults([]); return; }
|
||||
const isDigit = /^\d+$/.test(raw);
|
||||
const query = normalizeText(raw);
|
||||
const nameMatches: UatEntry[] = [];
|
||||
const countyOnly: UatEntry[] = [];
|
||||
for (const item of uatData) {
|
||||
// Skip cities already in queue
|
||||
if (state?.cities.some((c) => c.siruta === item.siruta)) continue;
|
||||
if (isDigit) {
|
||||
if (item.siruta.startsWith(raw)) nameMatches.push(item);
|
||||
} else {
|
||||
if (normalizeText(item.name).includes(query)) nameMatches.push(item);
|
||||
else if (item.county && normalizeText(item.county).includes(query))
|
||||
countyOnly.push(item);
|
||||
}
|
||||
}
|
||||
setUatResults([...nameMatches, ...countyOnly].slice(0, 10));
|
||||
}, [deferredUatQuery, uatData, state?.cities]);
|
||||
|
||||
const doAction = async (body: Record<string, unknown>) => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/eterra/weekend-sync", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
setFetchError(data.error ?? `Eroare: ${res.status}`);
|
||||
} else {
|
||||
setFetchError(null);
|
||||
}
|
||||
await fetchState();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Actiune esuata";
|
||||
setFetchError(msg);
|
||||
}
|
||||
setActionLoading(false);
|
||||
};
|
||||
|
||||
const handleAddUat = async (uat: UatEntry) => {
|
||||
await doAction({
|
||||
action: "add",
|
||||
siruta: uat.siruta,
|
||||
name: uat.name,
|
||||
county: uat.county ?? "",
|
||||
priority: 3,
|
||||
});
|
||||
setUatQuery("");
|
||||
setUatResults([]);
|
||||
setShowUatResults(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl py-12 text-center text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 mx-auto mb-3 animate-spin opacity-40" />
|
||||
<p>Se incarca...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cities = state?.cities ?? [];
|
||||
const totalSteps = cities.length * STEPS.length;
|
||||
const doneSteps = cities.reduce(
|
||||
(sum, c) => sum + STEPS.filter((s) => c.steps[s] === "done").length,
|
||||
0,
|
||||
);
|
||||
const progressPct = totalSteps > 0 ? Math.round((doneSteps / totalSteps) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<Moon className="h-6 w-6 text-indigo-500" />
|
||||
Weekend Deep Sync
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Sincronizare Magic completa pentru municipii mari — Vin/Sam/Dum
|
||||
23:00-04:00
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{lastRefresh && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{lastRefresh.toLocaleTimeString("ro-RO", { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
|
||||
</span>
|
||||
)}
|
||||
{syncStatus !== "running" && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={actionLoading}
|
||||
onClick={() => {
|
||||
if (window.confirm("Descarca terenuri + cladiri pentru orasele pending?"))
|
||||
void doAction({ action: "trigger", onlySteps: ["sync_terenuri", "sync_cladiri"] });
|
||||
}}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
Descarca parcele
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-indigo-600 border-indigo-300 hover:bg-indigo-50 dark:text-indigo-400 dark:border-indigo-700 dark:hover:bg-indigo-950/30"
|
||||
disabled={actionLoading}
|
||||
onClick={() => {
|
||||
if (window.confirm("Pornesti sincronizarea completa? Va procesa toti pasii pending."))
|
||||
void doAction({ action: "trigger" });
|
||||
}}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
Sync complet
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void fetchState()}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4 mr-1", loading && "animate-spin")} />
|
||||
Reincarca
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection error banner */}
|
||||
{fetchError && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-rose-200 bg-rose-50 px-4 py-2.5 text-sm text-rose-700 dark:border-rose-800 dark:bg-rose-950/30 dark:text-rose-400">
|
||||
<WifiOff className="h-4 w-4 shrink-0" />
|
||||
<span>{fetchError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live status banner */}
|
||||
{syncStatus === "running" && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-indigo-200 bg-indigo-50 px-4 py-2.5 text-sm text-indigo-700 dark:border-indigo-800 dark:bg-indigo-950/30 dark:text-indigo-400">
|
||||
<Activity className="h-4 w-4 shrink-0 animate-pulse" />
|
||||
<span className="font-medium">Sincronizarea ruleaza</span>
|
||||
{currentActivity && (
|
||||
<span>
|
||||
— {currentActivity.city} / {STEP_LABELS[currentActivity.step as StepName] ?? currentActivity.step}
|
||||
</span>
|
||||
)}
|
||||
<Loader2 className="h-3.5 w-3.5 ml-auto animate-spin opacity-50" />
|
||||
</div>
|
||||
)}
|
||||
{syncStatus === "error" && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-rose-200 bg-rose-50 px-4 py-2.5 text-sm text-rose-700 dark:border-rose-800 dark:bg-rose-950/30 dark:text-rose-400">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
<span className="font-medium">Erori in ultimul ciclu</span>
|
||||
<span>
|
||||
— {cities.filter((c) => STEPS.some((s) => c.steps[s] === "error")).map((c) => c.name).join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{syncStatus === "waiting" && !fetchError && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 px-4 py-2.5 text-sm text-amber-700 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-400">
|
||||
<Clock className="h-4 w-4 shrink-0" />
|
||||
<span>Fereastra weekend activa — se asteapta urmatorul slot de procesare</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats bar */}
|
||||
<Card>
|
||||
<CardContent className="py-3 px-4">
|
||||
<div className="flex items-center gap-4 flex-wrap text-sm">
|
||||
<span>
|
||||
<span className="font-semibold">{cities.length}</span> orase in
|
||||
coada
|
||||
</span>
|
||||
<span>
|
||||
Progres ciclu:{" "}
|
||||
<span className="font-semibold">{doneSteps}/{totalSteps}</span>{" "}
|
||||
pasi ({progressPct}%)
|
||||
</span>
|
||||
{state?.totalSessions != null && state.totalSessions > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
{state.totalSessions} sesiuni | {state.completedCycles ?? 0}{" "}
|
||||
cicluri complete
|
||||
</span>
|
||||
)}
|
||||
{state?.lastSessionDate && (
|
||||
<span className="text-muted-foreground">
|
||||
Ultima sesiune: {state.lastSessionDate}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{totalSteps > 0 && (
|
||||
<div className="h-2 w-full rounded-full bg-muted mt-2">
|
||||
<div
|
||||
className="h-2 rounded-full bg-indigo-500 transition-all duration-300"
|
||||
style={{ width: `${Math.max(1, progressPct)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* City cards */}
|
||||
<div className="space-y-3">
|
||||
{cities
|
||||
.sort((a, b) => a.priority - b.priority)
|
||||
.map((city) => {
|
||||
const doneCount = STEPS.filter(
|
||||
(s) => city.steps[s] === "done",
|
||||
).length;
|
||||
const hasError = STEPS.some((s) => city.steps[s] === "error");
|
||||
const allDone = doneCount === STEPS.length;
|
||||
const isActive = currentActivity?.city === city.name;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={city.siruta}
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
isActive && "border-indigo-300 ring-1 ring-indigo-200 dark:border-indigo-700 dark:ring-indigo-800",
|
||||
allDone && !isActive && "border-emerald-200 dark:border-emerald-800",
|
||||
hasError && !isActive && "border-rose-200 dark:border-rose-800",
|
||||
)}
|
||||
>
|
||||
<CardContent className="py-3 px-4 space-y-2">
|
||||
{/* City header */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<MapPin className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-semibold">{city.name}</span>
|
||||
{city.county && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
jud. {city.county}
|
||||
</span>
|
||||
)}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] font-mono"
|
||||
>
|
||||
{city.siruta}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px]"
|
||||
>
|
||||
P{city.priority}
|
||||
</Badge>
|
||||
|
||||
{/* Status icon */}
|
||||
{allDone ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500 ml-auto" />
|
||||
) : hasError ? (
|
||||
<XCircle className="h-4 w-4 text-rose-500 ml-auto" />
|
||||
) : doneCount > 0 ? (
|
||||
<Clock className="h-4 w-4 text-amber-500 ml-auto" />
|
||||
) : null}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-1 ml-auto">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-[10px]"
|
||||
disabled={actionLoading}
|
||||
onClick={() =>
|
||||
void doAction({
|
||||
action: "reset",
|
||||
siruta: city.siruta,
|
||||
})
|
||||
}
|
||||
title="Reseteaza progresul"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-[10px] text-destructive"
|
||||
disabled={actionLoading}
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Stergi ${city.name} din coada?`,
|
||||
)
|
||||
)
|
||||
void doAction({
|
||||
action: "remove",
|
||||
siruta: city.siruta,
|
||||
});
|
||||
}}
|
||||
title="Sterge din coada"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Steps progress */}
|
||||
<div className="flex gap-1.5">
|
||||
{STEPS.map((step) => {
|
||||
const status = city.steps[step];
|
||||
const isRunning = isActive && currentActivity?.step === step;
|
||||
return (
|
||||
<div
|
||||
key={step}
|
||||
className={cn(
|
||||
"flex-1 rounded-md border px-2 py-1.5 text-center text-[11px] transition-colors",
|
||||
isRunning &&
|
||||
"bg-indigo-50 border-indigo-300 text-indigo-700 dark:bg-indigo-950/30 dark:border-indigo-700 dark:text-indigo-400 animate-pulse",
|
||||
!isRunning && status === "done" &&
|
||||
"bg-emerald-50 border-emerald-200 text-emerald-700 dark:bg-emerald-950/30 dark:border-emerald-800 dark:text-emerald-400",
|
||||
!isRunning && status === "error" &&
|
||||
"bg-rose-50 border-rose-200 text-rose-700 dark:bg-rose-950/30 dark:border-rose-800 dark:text-rose-400",
|
||||
!isRunning && status === "pending" &&
|
||||
"bg-muted/30 border-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isRunning && <Loader2 className="h-3 w-3 inline mr-1 animate-spin" />}
|
||||
{STEP_LABELS[step]}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* DB stats + error */}
|
||||
<div className="flex items-center gap-3 text-[11px] text-muted-foreground">
|
||||
{city.dbStats && city.dbStats.total > 0 && (
|
||||
<>
|
||||
<span>
|
||||
DB: {city.dbStats.terenuri.toLocaleString("ro")} ter.
|
||||
+ {city.dbStats.cladiri.toLocaleString("ro")} clad.
|
||||
</span>
|
||||
{city.dbStats.enriched > 0 && (
|
||||
<span className="text-teal-600 dark:text-teal-400">
|
||||
{city.dbStats.enriched.toLocaleString("ro")}{" "}
|
||||
enriched
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{city.lastActivity && (
|
||||
<span>
|
||||
Ultima activitate:{" "}
|
||||
{new Date(city.lastActivity).toLocaleString("ro-RO", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{city.errorMessage && (
|
||||
<span className="text-rose-500 truncate max-w-[300px]">
|
||||
{city.errorMessage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Add city — UAT autocomplete */}
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Adauga oras in coada
|
||||
</h3>
|
||||
<div className="relative" ref={uatRef}>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder="Cauta UAT — scrie nume sau cod SIRUTA..."
|
||||
value={uatQuery}
|
||||
onChange={(e) => {
|
||||
setUatQuery(e.target.value);
|
||||
setShowUatResults(true);
|
||||
}}
|
||||
onFocus={() => setShowUatResults(true)}
|
||||
onBlur={() => setTimeout(() => setShowUatResults(false), 150)}
|
||||
className="pl-9 h-9"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
{showUatResults && uatResults.length > 0 && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg max-h-60 overflow-auto">
|
||||
{uatResults.map((item) => (
|
||||
<button
|
||||
key={item.siruta}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-sm hover:bg-accent transition-colors text-left"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
void handleAddUat(item);
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<MapPin className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="font-medium">{item.name}</span>
|
||||
<span className="text-muted-foreground">
|
||||
({item.siruta})
|
||||
</span>
|
||||
{item.county && (
|
||||
<span className="text-muted-foreground">
|
||||
— jud. {item.county}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<Plus className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Reset all button */}
|
||||
{cities.length > 0 && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
disabled={actionLoading}
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
"Resetezi progresul pentru TOATE orasele? Se va reporni ciclul de la zero.",
|
||||
)
|
||||
)
|
||||
void doAction({ action: "reset_all" });
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-1" />
|
||||
Reseteaza tot
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info footer */}
|
||||
<div className="text-xs text-muted-foreground space-y-1 pb-4">
|
||||
<p>
|
||||
Sincronizarea ruleaza automat Vineri, Sambata si Duminica noaptea
|
||||
(23:00-04:00). Procesarea e intercalata intre orase si se reia de
|
||||
unde a ramas. Pagina se actualizeaza automat la fiecare {syncStatus === "running" ? "15" : "60"} secunde.
|
||||
</p>
|
||||
<p>
|
||||
Prioritate: P1 = primele procesate, P2 = urmatoarele, P3 = adaugate
|
||||
manual. In cadrul aceleiasi prioritati, ordinea e aleatorie.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,236 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
const NAMESPACE = "address-book";
|
||||
const PREFIX = "contact:";
|
||||
|
||||
// ─── Auth: Bearer token OR NextAuth session ─────────────────────────
|
||||
// External tools use: Authorization: Bearer <ADDRESSBOOK_API_KEY>
|
||||
// Browser users fall through middleware (NextAuth session)
|
||||
|
||||
function checkBearerAuth(req: NextRequest): boolean {
|
||||
const secret = process.env.ADDRESSBOOK_API_KEY;
|
||||
if (!secret) return false;
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
const token = authHeader?.replace("Bearer ", "");
|
||||
return token === secret;
|
||||
}
|
||||
|
||||
// ─── GET /api/address-book ──────────────────────────────────────────
|
||||
// Query params:
|
||||
// ?id=<uuid> → single contact
|
||||
// ?q=<search> → search by name/company/email/phone
|
||||
// ?type=<ContactType> → filter by type
|
||||
// (no params) → all contacts
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!checkBearerAuth(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const params = req.nextUrl.searchParams;
|
||||
const id = params.get("id");
|
||||
const q = params.get("q")?.toLowerCase();
|
||||
const type = params.get("type");
|
||||
|
||||
try {
|
||||
// Single contact by ID
|
||||
if (id) {
|
||||
const item = await prisma.keyValueStore.findUnique({
|
||||
where: { namespace_key: { namespace: NAMESPACE, key: `${PREFIX}${id}` } },
|
||||
});
|
||||
if (!item) {
|
||||
return NextResponse.json({ error: "Contact not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ contact: item.value });
|
||||
}
|
||||
|
||||
// All contacts (with optional filtering)
|
||||
const items = await prisma.keyValueStore.findMany({
|
||||
where: { namespace: NAMESPACE },
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
|
||||
let contacts: Record<string, unknown>[] = [];
|
||||
for (const item of items) {
|
||||
if (!item.key.startsWith(PREFIX)) continue;
|
||||
const val = item.value as Record<string, unknown>;
|
||||
if (!val) continue;
|
||||
|
||||
// Type filter
|
||||
if (type && val.type !== type) continue;
|
||||
|
||||
// Search filter
|
||||
if (q) {
|
||||
const name = String(val.name ?? "").toLowerCase();
|
||||
const company = String(val.company ?? "").toLowerCase();
|
||||
const email = String(val.email ?? "").toLowerCase();
|
||||
const phone = String(val.phone ?? "");
|
||||
if (
|
||||
!name.includes(q) &&
|
||||
!company.includes(q) &&
|
||||
!email.includes(q) &&
|
||||
!phone.includes(q)
|
||||
) continue;
|
||||
}
|
||||
|
||||
contacts.push(val);
|
||||
}
|
||||
|
||||
// Sort by name/company
|
||||
contacts.sort((a, b) => {
|
||||
const aLabel = String(a.name || a.company || "");
|
||||
const bLabel = String(b.name || b.company || "");
|
||||
return aLabel.localeCompare(bLabel, "ro");
|
||||
});
|
||||
|
||||
return NextResponse.json({ contacts, total: contacts.length });
|
||||
} catch (error) {
|
||||
console.error("Address book GET error:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── POST /api/address-book ─────────────────────────────────────────
|
||||
// Body: { name?, company?, type?, email?, phone?, ... }
|
||||
// Returns: { contact: AddressContact }
|
||||
// Validation: at least name OR company required
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!checkBearerAuth(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
|
||||
const name = String(body.name ?? "").trim();
|
||||
const company = String(body.company ?? "").trim();
|
||||
|
||||
if (!name && !company) {
|
||||
return NextResponse.json(
|
||||
{ error: "Cel puțin name sau company este obligatoriu" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-detect type: if only company → institution
|
||||
const autoType = !name && company ? "institution" : "client";
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const id = body.id ?? uuid();
|
||||
|
||||
const contact = {
|
||||
id,
|
||||
name,
|
||||
company,
|
||||
type: body.type ?? autoType,
|
||||
email: String(body.email ?? "").trim(),
|
||||
email2: String(body.email2 ?? "").trim(),
|
||||
phone: String(body.phone ?? "").trim(),
|
||||
phone2: String(body.phone2 ?? "").trim(),
|
||||
address: String(body.address ?? "").trim(),
|
||||
department: String(body.department ?? "").trim(),
|
||||
role: String(body.role ?? "").trim(),
|
||||
website: String(body.website ?? "").trim(),
|
||||
projectIds: Array.isArray(body.projectIds) ? body.projectIds : [],
|
||||
contactPersons: Array.isArray(body.contactPersons) ? body.contactPersons : [],
|
||||
tags: Array.isArray(body.tags) ? body.tags : [],
|
||||
notes: String(body.notes ?? "").trim(),
|
||||
visibility: body.visibility ?? "company",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await prisma.keyValueStore.upsert({
|
||||
where: { namespace_key: { namespace: NAMESPACE, key: `${PREFIX}${id}` } },
|
||||
update: { value: contact as unknown as Prisma.InputJsonValue },
|
||||
create: { namespace: NAMESPACE, key: `${PREFIX}${id}`, value: contact as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
|
||||
return NextResponse.json({ contact }, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("Address book POST error:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PUT /api/address-book ──────────────────────────────────────────
|
||||
// Body: { id: "<uuid>", ...fields to update }
|
||||
// Merges with existing contact, updates updatedAt
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
if (!checkBearerAuth(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const id = body.id;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await prisma.keyValueStore.findUnique({
|
||||
where: { namespace_key: { namespace: NAMESPACE, key: `${PREFIX}${id}` } },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: "Contact not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const prev = existing.value as Record<string, unknown>;
|
||||
const updated = {
|
||||
...prev,
|
||||
...body,
|
||||
id: prev.id, // never overwrite
|
||||
createdAt: prev.createdAt, // never overwrite
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Re-validate: name OR company
|
||||
if (!String(updated.name ?? "").trim() && !String(updated.company ?? "").trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: "Cel puțin name sau company este obligatoriu" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.keyValueStore.update({
|
||||
where: { namespace_key: { namespace: NAMESPACE, key: `${PREFIX}${id}` } },
|
||||
data: { value: updated as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
|
||||
return NextResponse.json({ contact: updated });
|
||||
} catch (error) {
|
||||
console.error("Address book PUT error:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DELETE /api/address-book?id=<uuid> ─────────────────────────────
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
if (!checkBearerAuth(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const id = req.nextUrl.searchParams.get("id");
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.keyValueStore.delete({
|
||||
where: { namespace_key: { namespace: NAMESPACE, key: `${PREFIX}${id}` } },
|
||||
}).catch(() => { /* ignore if not found */ });
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Address book DELETE error:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { EpayClient } from "@/modules/parcel-sync/services/epay-client";
|
||||
import {
|
||||
getEpayCredentials,
|
||||
getEpaySessionStatus,
|
||||
updateEpayCredits,
|
||||
} from "@/modules/parcel-sync/services/epay-session-store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** GET /api/ancpi/credits — current credit balance (live from ePay) */
|
||||
export async function GET() {
|
||||
try {
|
||||
const status = getEpaySessionStatus();
|
||||
if (!status.connected) {
|
||||
return NextResponse.json({ credits: null, connected: false });
|
||||
}
|
||||
|
||||
// Return cached if checked within last 60 seconds
|
||||
const lastChecked = status.creditsCheckedAt
|
||||
? new Date(status.creditsCheckedAt).getTime()
|
||||
: 0;
|
||||
if (Date.now() - lastChecked < 60_000 && status.credits != null) {
|
||||
return NextResponse.json({
|
||||
credits: status.credits,
|
||||
connected: true,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch live from ePay
|
||||
const creds = getEpayCredentials();
|
||||
if (!creds) {
|
||||
return NextResponse.json({ credits: null, connected: false });
|
||||
}
|
||||
|
||||
const client = await EpayClient.create(creds.username, creds.password);
|
||||
const credits = await client.getCredits();
|
||||
updateEpayCredits(credits);
|
||||
|
||||
return NextResponse.json({ credits, connected: true, cached: false });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Eroare";
|
||||
return NextResponse.json({ error: message, credits: null }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import { getCfExtractStream } from "@/modules/parcel-sync/services/epay-storage";
|
||||
import JSZip from "jszip";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/ancpi/download-zip?ids=id1,id2,id3
|
||||
*
|
||||
* Streams a ZIP file containing all requested CF extract PDFs.
|
||||
* Files named: {index:02d}_Extras CF_{nrCadastral} - {DD-MM-YYYY}.pdf
|
||||
* Index = position in the ids array (preserves list order).
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const idsParam = url.searchParams.get("ids");
|
||||
|
||||
if (!idsParam) {
|
||||
return NextResponse.json(
|
||||
{ error: "Parametru 'ids' lipsa." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const ids = idsParam.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
if (ids.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Lista de id-uri goala." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch all extract records
|
||||
const extracts = await prisma.cfExtract.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: {
|
||||
id: true,
|
||||
nrCadastral: true,
|
||||
minioPath: true,
|
||||
documentDate: true,
|
||||
completedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Build a map for ordering
|
||||
const extractMap = new Map(extracts.map((e) => [e.id, e]));
|
||||
|
||||
const zip = new JSZip();
|
||||
let filesAdded = 0;
|
||||
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
const id = ids[i]!;
|
||||
const extract = extractMap.get(id);
|
||||
if (!extract?.minioPath) continue;
|
||||
|
||||
const dateForName = extract.documentDate ?? extract.completedAt ?? new Date();
|
||||
const d = new Date(dateForName);
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const yyyy = d.getFullYear();
|
||||
|
||||
const idx = String(i + 1).padStart(2, "0");
|
||||
const fileName = `${idx}_Extras CF_${extract.nrCadastral} - ${dd}-${mm}-${yyyy}.pdf`;
|
||||
|
||||
try {
|
||||
const stream = await getCfExtractStream(extract.minioPath);
|
||||
|
||||
// Collect stream into buffer
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array));
|
||||
}
|
||||
const buffer = Buffer.concat(chunks);
|
||||
|
||||
zip.file(fileName, buffer);
|
||||
filesAdded++;
|
||||
} catch (err) {
|
||||
console.error(`[download-zip] Failed to fetch ${extract.minioPath}:`, err);
|
||||
// Skip this file but continue
|
||||
}
|
||||
}
|
||||
|
||||
if (filesAdded === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Niciun fisier PDF gasit." },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const zipBuffer = await zip.generateAsync({
|
||||
type: "nodebuffer",
|
||||
compression: "DEFLATE",
|
||||
compressionOptions: { level: 6 },
|
||||
});
|
||||
|
||||
const today = new Date();
|
||||
const todayStr = `${String(today.getDate()).padStart(2, "0")}-${String(today.getMonth() + 1).padStart(2, "0")}-${today.getFullYear()}`;
|
||||
const archiveName = `Extrase_CF_${filesAdded}_${todayStr}.zip`;
|
||||
|
||||
return new Response(new Uint8Array(zipBuffer), {
|
||||
headers: {
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": `attachment; filename="${encodeURIComponent(archiveName)}"`,
|
||||
"Content-Length": String(zipBuffer.length),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Eroare server";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import { getCfExtractStream } from "@/modules/parcel-sync/services/epay-storage";
|
||||
import { Readable } from "stream";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/ancpi/download?id={extractId}
|
||||
*
|
||||
* Streams the CF extract PDF from MinIO with proper filename.
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const id = url.searchParams.get("id");
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Parametru 'id' lipsă." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const extract = await prisma.cfExtract.findUnique({
|
||||
where: { id },
|
||||
select: { minioPath: true, nrCadastral: true, minioIndex: true },
|
||||
});
|
||||
|
||||
if (!extract?.minioPath) {
|
||||
return NextResponse.json(
|
||||
{ error: "Extras CF negăsit sau fără fișier." },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const stream = await getCfExtractStream(extract.minioPath);
|
||||
|
||||
// Convert Node.js Readable to Web ReadableStream
|
||||
const webStream = new ReadableStream({
|
||||
start(controller) {
|
||||
stream.on("data", (chunk: Buffer) =>
|
||||
controller.enqueue(new Uint8Array(chunk)),
|
||||
);
|
||||
stream.on("end", () => controller.close());
|
||||
stream.on("error", (err: Error) => controller.error(err));
|
||||
},
|
||||
});
|
||||
|
||||
// Build display filename
|
||||
const fileName =
|
||||
extract.minioPath.split("/").pop() ??
|
||||
`Extras_CF_${extract.nrCadastral}.pdf`;
|
||||
|
||||
return new Response(webStream, {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="${encodeURIComponent(fileName)}"`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Eroare server";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getEpayCredentials } from "@/modules/parcel-sync/services/epay-session-store";
|
||||
import {
|
||||
enqueueOrder,
|
||||
enqueueBatch,
|
||||
} from "@/modules/parcel-sync/services/epay-queue";
|
||||
import type { CfExtractCreateInput } from "@/modules/parcel-sync/services/epay-types";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Nonce-based idempotency cache */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type NonceEntry = {
|
||||
timestamp: number;
|
||||
response: { orders: Array<{ id: string; nrCadastral: string; status: string }> };
|
||||
};
|
||||
|
||||
const NONCE_TTL_MS = 60_000; // 60 seconds
|
||||
|
||||
const gNonce = globalThis as {
|
||||
__orderNonceMap?: Map<string, NonceEntry>;
|
||||
};
|
||||
if (!gNonce.__orderNonceMap) gNonce.__orderNonceMap = new Map();
|
||||
|
||||
function cleanupNonceMap(): void {
|
||||
const now = Date.now();
|
||||
const map = gNonce.__orderNonceMap!;
|
||||
for (const [key, entry] of map) {
|
||||
if (now - entry.timestamp > NONCE_TTL_MS) {
|
||||
map.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ancpi/order — create one or more CF extract orders.
|
||||
*
|
||||
* Body: { parcels: CfExtractCreateInput[], nonce?: string }
|
||||
*
|
||||
* If a `nonce` is provided and was already seen within the last 60 seconds,
|
||||
* the previous response is returned instead of creating duplicate orders.
|
||||
*
|
||||
* Returns: { orders: [{ id, nrCadastral, status }], deduplicated?: boolean }
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const creds = getEpayCredentials();
|
||||
if (!creds) {
|
||||
return NextResponse.json(
|
||||
{ error: "Nu ești conectat la ePay ANCPI." },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await req.json()) as {
|
||||
parcels?: CfExtractCreateInput[];
|
||||
nonce?: string;
|
||||
};
|
||||
|
||||
const parcels = body.parcels ?? [];
|
||||
if (parcels.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Nicio parcelă specificată." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Nonce idempotency check ──
|
||||
cleanupNonceMap();
|
||||
if (body.nonce) {
|
||||
const cached = gNonce.__orderNonceMap!.get(body.nonce);
|
||||
if (cached && Date.now() - cached.timestamp < NONCE_TTL_MS) {
|
||||
console.log(
|
||||
`[ancpi/order] Nonce dedup hit: "${body.nonce}" — returning cached response`,
|
||||
);
|
||||
return NextResponse.json({
|
||||
...cached.response,
|
||||
deduplicated: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
for (const p of parcels) {
|
||||
if (!p.nrCadastral || p.judetIndex == null || p.uatId == null) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Date lipsă pentru parcela ${p.nrCadastral ?? "?"}. Necesare: nrCadastral, judetIndex, uatId.`,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let responseBody: {
|
||||
orders: Array<{ id: string; nrCadastral: string; status: string }>;
|
||||
};
|
||||
|
||||
if (parcels.length === 1) {
|
||||
const id = await enqueueOrder(parcels[0]!);
|
||||
responseBody = {
|
||||
orders: [
|
||||
{
|
||||
id,
|
||||
nrCadastral: parcels[0]!.nrCadastral,
|
||||
status: "queued",
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
const ids = await enqueueBatch(parcels);
|
||||
responseBody = {
|
||||
orders: ids.map((id, i) => ({
|
||||
id,
|
||||
nrCadastral: parcels[i]?.nrCadastral ?? "",
|
||||
status: "queued",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Cache response for nonce ──
|
||||
if (body.nonce) {
|
||||
gNonce.__orderNonceMap!.set(body.nonce, {
|
||||
timestamp: Date.now(),
|
||||
response: responseBody,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(responseBody);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Eroare server";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/ancpi/orders — list all CF extract orders.
|
||||
*
|
||||
* Query params:
|
||||
* ?nrCadastral=123 — single cadastral number
|
||||
* ?nrCadastral=123,456 — comma-separated for batch status check
|
||||
* ?status=completed — filter by status
|
||||
* ?limit=50&offset=0 — pagination
|
||||
*
|
||||
* When nrCadastral contains commas, returns an extra `statusMap` field:
|
||||
* { orders, total, statusMap: { "123": "valid", "456": "expired", "789": "none" } }
|
||||
* - "valid" = completed + expiresAt > now
|
||||
* - "expired" = completed + expiresAt <= now
|
||||
* - "none" = no completed record
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const nrCadastralParam = url.searchParams.get("nrCadastral") || undefined;
|
||||
const status = url.searchParams.get("status") || undefined;
|
||||
const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "50"), 200);
|
||||
const offset = parseInt(url.searchParams.get("offset") ?? "0");
|
||||
|
||||
// Check if multi-cadastral query
|
||||
const cadastralNumbers = nrCadastralParam
|
||||
? nrCadastralParam.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
const isMulti = cadastralNumbers.length > 1;
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (cadastralNumbers.length === 1) {
|
||||
where.nrCadastral = cadastralNumbers[0];
|
||||
} else if (isMulti) {
|
||||
where.nrCadastral = { in: cadastralNumbers };
|
||||
}
|
||||
if (status) where.status = status;
|
||||
|
||||
const [orders, total] = await Promise.all([
|
||||
prisma.cfExtract.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
}),
|
||||
prisma.cfExtract.count({ where }),
|
||||
]);
|
||||
|
||||
// Build statusMap for multi-cadastral queries (or single if requested)
|
||||
if (cadastralNumbers.length > 0) {
|
||||
const now = new Date();
|
||||
// For status map, we need completed records for each cadastral number
|
||||
const completedRecords = await prisma.cfExtract.findMany({
|
||||
where: {
|
||||
nrCadastral: { in: cadastralNumbers },
|
||||
status: "completed",
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
nrCadastral: true,
|
||||
expiresAt: true,
|
||||
completedAt: true,
|
||||
minioPath: true,
|
||||
},
|
||||
});
|
||||
|
||||
const statusMap: Record<string, string> = {};
|
||||
const latestById: Record<string, typeof completedRecords[number]> = {};
|
||||
|
||||
// Find latest completed record per cadastral number
|
||||
for (const rec of completedRecords) {
|
||||
const existing = latestById[rec.nrCadastral];
|
||||
if (!existing) {
|
||||
latestById[rec.nrCadastral] = rec;
|
||||
}
|
||||
}
|
||||
|
||||
for (const nr of cadastralNumbers) {
|
||||
const rec = latestById[nr];
|
||||
if (!rec) {
|
||||
statusMap[nr] = "none";
|
||||
} else if (rec.expiresAt && rec.expiresAt <= now) {
|
||||
statusMap[nr] = "expired";
|
||||
} else {
|
||||
statusMap[nr] = "valid";
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for active (in-progress) orders
|
||||
const activeRecords = await prisma.cfExtract.findMany({
|
||||
where: {
|
||||
nrCadastral: { in: cadastralNumbers },
|
||||
status: {
|
||||
in: ["pending", "queued", "cart", "searching", "ordering", "polling", "downloading"],
|
||||
},
|
||||
},
|
||||
select: { nrCadastral: true },
|
||||
});
|
||||
|
||||
for (const rec of activeRecords) {
|
||||
// If there's an active order, mark as "processing" (takes priority over "none")
|
||||
if (statusMap[rec.nrCadastral] === "none") {
|
||||
statusMap[rec.nrCadastral] = "processing";
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ orders, total, statusMap, latestById });
|
||||
}
|
||||
|
||||
return NextResponse.json({ orders, total });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Eroare server";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { EpayClient } from "@/modules/parcel-sync/services/epay-client";
|
||||
import {
|
||||
createEpaySession,
|
||||
destroyEpaySession,
|
||||
getEpaySessionStatus,
|
||||
} from "@/modules/parcel-sync/services/epay-session-store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** GET /api/ancpi/session — status + credits */
|
||||
export async function GET() {
|
||||
return NextResponse.json(getEpaySessionStatus());
|
||||
}
|
||||
|
||||
/** POST /api/ancpi/session — connect or disconnect */
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = (await req.json()) as {
|
||||
action?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
if (body.action === "disconnect") {
|
||||
destroyEpaySession();
|
||||
return NextResponse.json({ success: true, disconnected: true });
|
||||
}
|
||||
|
||||
// Connect
|
||||
const username = (
|
||||
body.username ?? process.env.ANCPI_USERNAME ?? ""
|
||||
).trim();
|
||||
const password = (
|
||||
body.password ?? process.env.ANCPI_PASSWORD ?? ""
|
||||
).trim();
|
||||
|
||||
if (!username || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: "Credențiale ANCPI lipsă" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const client = await EpayClient.create(username, password);
|
||||
const credits = await client.getCredits();
|
||||
createEpaySession(username, password, credits);
|
||||
|
||||
return NextResponse.json({ success: true, credits });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Eroare server";
|
||||
const status = message.toLowerCase().includes("login") ? 401 : 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { EpayClient } from "@/modules/parcel-sync/services/epay-client";
|
||||
import {
|
||||
createEpaySession,
|
||||
getEpayCredentials,
|
||||
} from "@/modules/parcel-sync/services/epay-session-store";
|
||||
import { enqueueBatch } from "@/modules/parcel-sync/services/epay-queue";
|
||||
import { storeCfExtract } from "@/modules/parcel-sync/services/epay-storage";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Dedup for test order step (prevents re-enqueue on page refresh) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type TestOrderEntry = {
|
||||
timestamp: number;
|
||||
extractIds: string[];
|
||||
parcels: Array<{ nrCadastral: string; uatName: string; siruta: string; extractId: string | undefined }>;
|
||||
};
|
||||
|
||||
const TEST_ORDER_DEDUP_TTL_MS = 30_000; // 30 seconds
|
||||
|
||||
const gTestDedup = globalThis as {
|
||||
__testOrderDedup?: TestOrderEntry | null;
|
||||
};
|
||||
if (gTestDedup.__testOrderDedup === undefined) gTestDedup.__testOrderDedup = null;
|
||||
|
||||
/**
|
||||
* GET /api/ancpi/test?step=login|uats|order|download
|
||||
*
|
||||
* ePay internal county IDs = eTerra WORKSPACE_IDs.
|
||||
* ePay UAT IDs = SIRUTA codes.
|
||||
* Zero discovery calls needed!
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const step = url.searchParams.get("step") ?? "login";
|
||||
|
||||
const username = process.env.ANCPI_USERNAME ?? "";
|
||||
const password = process.env.ANCPI_PASSWORD ?? "";
|
||||
|
||||
if (!username || !password) {
|
||||
return NextResponse.json({ error: "ANCPI credentials not configured" });
|
||||
}
|
||||
|
||||
try {
|
||||
// ── login ──
|
||||
if (step === "login") {
|
||||
const client = await EpayClient.create(username, password);
|
||||
const credits = await client.getCredits();
|
||||
createEpaySession(username, password, credits);
|
||||
return NextResponse.json({ step: "login", success: true, credits });
|
||||
}
|
||||
|
||||
// ── uats ── Verify that ePay county/UAT IDs match our WORKSPACE_ID/SIRUTA
|
||||
if (step === "uats") {
|
||||
const client = await EpayClient.create(username, password);
|
||||
await client.addToCart(14200);
|
||||
|
||||
// Get county list to confirm IDs match WORKSPACE_IDs
|
||||
const counties = await client.getCountyList();
|
||||
const clujCounty = counties.find((c) =>
|
||||
c.value.toUpperCase().includes("CLUJ"),
|
||||
);
|
||||
|
||||
// Get UAT list to confirm IDs match SIRUTA codes
|
||||
let uatList: { id: number; value: string }[] = [];
|
||||
if (clujCounty) {
|
||||
uatList = await client.getUatList(clujCounty.id);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
step: "uats",
|
||||
totalCounties: counties.length,
|
||||
clujCounty,
|
||||
note: clujCounty?.id === 127
|
||||
? "CONFIRMED: ePay county ID = WORKSPACE_ID (127)"
|
||||
: `WARNING: expected 127, got ${clujCounty?.id}`,
|
||||
totalUats: uatList.length,
|
||||
clujNapoca: uatList.find((u) => u.id === 54975),
|
||||
feleacu: uatList.find((u) => u.id === 57582),
|
||||
floresti: uatList.find((u) => u.id === 57706),
|
||||
});
|
||||
}
|
||||
|
||||
// ── download ── Re-download PDFs from all known ePay orders
|
||||
if (step === "download") {
|
||||
const client = await EpayClient.create(username, password);
|
||||
createEpaySession(username, password, await client.getCredits());
|
||||
|
||||
// All known order IDs (MinIO + DB were cleaned, need re-download)
|
||||
// Single orders: mapping unknown — use documentsByCadastral to discover CF
|
||||
const singleOrderIds = ["9685480", "9685481", "9685482", "9685483", "9685484"];
|
||||
// Batch orders: documentsByCadastral maps CF -> doc correctly
|
||||
const batchOrderIds = ["9685487", "9685488"];
|
||||
const allOrderIds = [...singleOrderIds, ...batchOrderIds];
|
||||
|
||||
// UAT name lookup for DB records
|
||||
const uatLookup: Record<string, { uatId: number; uatName: string }> = {
|
||||
"345295": { uatId: 54975, uatName: "Cluj-Napoca" },
|
||||
"63565": { uatId: 57582, uatName: "Feleacu" },
|
||||
"88089": { uatId: 57706, uatName: "Floresti" },
|
||||
"61904": { uatId: 57582, uatName: "Feleacu" },
|
||||
"309952": { uatId: 54975, uatName: "Cluj-Napoca" },
|
||||
};
|
||||
|
||||
const results: Array<{
|
||||
orderId: string;
|
||||
nrCadastral: string;
|
||||
status: string;
|
||||
documents: number;
|
||||
downloaded: boolean;
|
||||
minioPath?: string;
|
||||
error?: string;
|
||||
}> = [];
|
||||
|
||||
for (const orderId of allOrderIds) {
|
||||
try {
|
||||
// Get order status — documentsByCadastral maps CF → doc
|
||||
const orderStatus = await client.getOrderStatus(orderId);
|
||||
console.log(
|
||||
`[ancpi-test] Order ${orderId}: status=${orderStatus.status}, docs=${orderStatus.documents.length}, byCF=${orderStatus.documentsByCadastral.size}`,
|
||||
);
|
||||
|
||||
if (orderStatus.documents.length === 0) {
|
||||
results.push({
|
||||
orderId,
|
||||
nrCadastral: "unknown",
|
||||
status: orderStatus.status,
|
||||
documents: 0,
|
||||
downloaded: false,
|
||||
error: "No documents found in order",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Process each document by cadastral number from the map
|
||||
if (orderStatus.documentsByCadastral.size > 0) {
|
||||
for (const [cfNumber, doc] of orderStatus.documentsByCadastral) {
|
||||
if (!doc.downloadValabil || doc.contentType !== "application/pdf") {
|
||||
results.push({
|
||||
orderId,
|
||||
nrCadastral: cfNumber,
|
||||
status: orderStatus.status,
|
||||
documents: orderStatus.documents.length,
|
||||
downloaded: false,
|
||||
error: "Document not downloadable or not PDF",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Download the PDF
|
||||
const pdfBuffer = await client.downloadDocument(doc.idDocument, 4);
|
||||
console.log(
|
||||
`[ancpi-test] Downloaded doc ${doc.idDocument} (CF ${cfNumber}): ${pdfBuffer.length} bytes`,
|
||||
);
|
||||
|
||||
// Resolve UAT info for this cadastral number
|
||||
const uat = uatLookup[cfNumber];
|
||||
const uatId = uat?.uatId ?? 0;
|
||||
const uatName = uat?.uatName ?? "Necunoscut";
|
||||
|
||||
// Store in MinIO
|
||||
const { path, index } = await storeCfExtract(
|
||||
pdfBuffer,
|
||||
cfNumber,
|
||||
{
|
||||
"ancpi-order-id": orderId,
|
||||
"nr-cadastral": cfNumber,
|
||||
judet: "CLUJ",
|
||||
uat: uatName,
|
||||
"data-document": doc.dataDocument ?? "",
|
||||
stare: orderStatus.status,
|
||||
produs: "EXI_ONLINE",
|
||||
},
|
||||
);
|
||||
|
||||
// Calculate dates
|
||||
const documentDate = doc.dataDocument
|
||||
? new Date(doc.dataDocument)
|
||||
: new Date();
|
||||
const expiresAt = new Date(documentDate);
|
||||
expiresAt.setDate(expiresAt.getDate() + 30);
|
||||
|
||||
// Always create new records (DB was cleaned)
|
||||
// Increment version for duplicate parcels
|
||||
const maxVersion = await prisma.cfExtract.aggregate({
|
||||
where: { nrCadastral: cfNumber },
|
||||
_max: { version: true },
|
||||
});
|
||||
|
||||
await prisma.cfExtract.create({
|
||||
data: {
|
||||
orderId,
|
||||
nrCadastral: cfNumber,
|
||||
nrCF: cfNumber,
|
||||
judetIndex: 127,
|
||||
judetName: "CLUJ",
|
||||
uatId,
|
||||
uatName,
|
||||
status: "completed",
|
||||
epayStatus: orderStatus.status,
|
||||
idDocument: doc.idDocument,
|
||||
documentName: doc.nume,
|
||||
documentDate,
|
||||
minioPath: path,
|
||||
minioIndex: index,
|
||||
completedAt: new Date(),
|
||||
expiresAt,
|
||||
version: (maxVersion._max.version ?? 0) + 1,
|
||||
},
|
||||
});
|
||||
|
||||
results.push({
|
||||
orderId,
|
||||
nrCadastral: cfNumber,
|
||||
status: orderStatus.status,
|
||||
documents: orderStatus.documents.length,
|
||||
downloaded: true,
|
||||
minioPath: path,
|
||||
});
|
||||
} catch (dlErr) {
|
||||
const msg = dlErr instanceof Error ? dlErr.message : String(dlErr);
|
||||
console.error(
|
||||
`[ancpi-test] Failed to download doc for CF ${cfNumber} in order ${orderId}:`,
|
||||
msg,
|
||||
);
|
||||
results.push({
|
||||
orderId,
|
||||
nrCadastral: cfNumber,
|
||||
status: "error",
|
||||
documents: orderStatus.documents.length,
|
||||
downloaded: false,
|
||||
error: msg,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: no CF mapping, process first downloadable document
|
||||
const doc = orderStatus.documents.find(
|
||||
(d) => d.downloadValabil && d.contentType === "application/pdf",
|
||||
);
|
||||
if (!doc) {
|
||||
results.push({
|
||||
orderId,
|
||||
nrCadastral: "unknown",
|
||||
status: orderStatus.status,
|
||||
documents: orderStatus.documents.length,
|
||||
downloaded: false,
|
||||
error: "No downloadable PDF and no CF mapping found",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to extract CF from document name (e.g. "Extras_Informare_345295.pdf")
|
||||
const cfFromName = doc.nume.match(/(\d{4,})/)?.[1] ?? "unknown";
|
||||
|
||||
const pdfBuffer = await client.downloadDocument(doc.idDocument, 4);
|
||||
console.log(
|
||||
`[ancpi-test] Downloaded doc ${doc.idDocument} (CF from name: ${cfFromName}): ${pdfBuffer.length} bytes`,
|
||||
);
|
||||
|
||||
const uat = uatLookup[cfFromName];
|
||||
const uatId = uat?.uatId ?? 0;
|
||||
const uatName = uat?.uatName ?? "Necunoscut";
|
||||
|
||||
const { path, index } = await storeCfExtract(
|
||||
pdfBuffer,
|
||||
cfFromName,
|
||||
{
|
||||
"ancpi-order-id": orderId,
|
||||
"nr-cadastral": cfFromName,
|
||||
judet: "CLUJ",
|
||||
uat: uatName,
|
||||
"data-document": doc.dataDocument ?? "",
|
||||
stare: orderStatus.status,
|
||||
produs: "EXI_ONLINE",
|
||||
},
|
||||
);
|
||||
|
||||
const documentDate = doc.dataDocument
|
||||
? new Date(doc.dataDocument)
|
||||
: new Date();
|
||||
const expiresAt = new Date(documentDate);
|
||||
expiresAt.setDate(expiresAt.getDate() + 30);
|
||||
|
||||
const maxVersion = await prisma.cfExtract.aggregate({
|
||||
where: { nrCadastral: cfFromName },
|
||||
_max: { version: true },
|
||||
});
|
||||
|
||||
await prisma.cfExtract.create({
|
||||
data: {
|
||||
orderId,
|
||||
nrCadastral: cfFromName,
|
||||
nrCF: cfFromName,
|
||||
judetIndex: 127,
|
||||
judetName: "CLUJ",
|
||||
uatId,
|
||||
uatName,
|
||||
status: "completed",
|
||||
epayStatus: orderStatus.status,
|
||||
idDocument: doc.idDocument,
|
||||
documentName: doc.nume,
|
||||
documentDate,
|
||||
minioPath: path,
|
||||
minioIndex: index,
|
||||
completedAt: new Date(),
|
||||
expiresAt,
|
||||
version: (maxVersion._max.version ?? 0) + 1,
|
||||
},
|
||||
});
|
||||
|
||||
results.push({
|
||||
orderId,
|
||||
nrCadastral: cfFromName,
|
||||
status: orderStatus.status,
|
||||
documents: orderStatus.documents.length,
|
||||
downloaded: true,
|
||||
minioPath: path,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
console.error(
|
||||
`[ancpi-test] Failed to process order ${orderId}:`,
|
||||
message,
|
||||
);
|
||||
results.push({
|
||||
orderId,
|
||||
nrCadastral: "unknown",
|
||||
status: "error",
|
||||
documents: 0,
|
||||
downloaded: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
step: "download",
|
||||
totalOrders: allOrderIds.length,
|
||||
results,
|
||||
summary: {
|
||||
downloaded: results.filter((r) => r.downloaded).length,
|
||||
failed: results.filter((r) => !r.downloaded).length,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── order ── Batch order test (USES 2 CREDITS!)
|
||||
// Uses enqueueBatch to create ONE ePay order for all parcels
|
||||
if (step === "order") {
|
||||
// ── Dedup check: prevent re-enqueue on page refresh ──
|
||||
const prevEntry = gTestDedup.__testOrderDedup;
|
||||
if (
|
||||
prevEntry &&
|
||||
Date.now() - prevEntry.timestamp < TEST_ORDER_DEDUP_TTL_MS
|
||||
) {
|
||||
console.log(
|
||||
`[ancpi-test] Order dedup hit: returning cached response (${Math.round((Date.now() - prevEntry.timestamp) / 1000)}s ago)`,
|
||||
);
|
||||
return NextResponse.json({
|
||||
step: "order",
|
||||
deduplicated: true,
|
||||
message: `Dedup: batch was enqueued ${Math.round((Date.now() - prevEntry.timestamp) / 1000)}s ago — returning cached IDs.`,
|
||||
extractIds: prevEntry.extractIds,
|
||||
parcels: prevEntry.parcels,
|
||||
});
|
||||
}
|
||||
|
||||
if (!getEpayCredentials()) {
|
||||
createEpaySession(username, password, 0);
|
||||
}
|
||||
|
||||
const client = await EpayClient.create(username, password);
|
||||
const credits = await client.getCredits();
|
||||
createEpaySession(username, password, credits);
|
||||
|
||||
const parcels = [
|
||||
{
|
||||
nrCadastral: "61904",
|
||||
siruta: "57582",
|
||||
judetIndex: 127,
|
||||
judetName: "CLUJ",
|
||||
uatId: 57582,
|
||||
uatName: "Feleacu",
|
||||
},
|
||||
{
|
||||
nrCadastral: "309952",
|
||||
siruta: "54975",
|
||||
judetIndex: 127,
|
||||
judetName: "CLUJ",
|
||||
uatId: 54975,
|
||||
uatName: "Cluj-Napoca",
|
||||
},
|
||||
];
|
||||
|
||||
if (credits < parcels.length) {
|
||||
return NextResponse.json({
|
||||
error: `Doar ${credits} credite, trebuie ${parcels.length}.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Use enqueueBatch — ONE order for all parcels
|
||||
const ids = await enqueueBatch(parcels);
|
||||
|
||||
const parcelResults = parcels.map((p, i) => ({
|
||||
nrCadastral: p.nrCadastral,
|
||||
uatName: p.uatName,
|
||||
siruta: p.siruta,
|
||||
extractId: ids[i],
|
||||
}));
|
||||
|
||||
// ── Store in dedup cache ──
|
||||
gTestDedup.__testOrderDedup = {
|
||||
timestamp: Date.now(),
|
||||
extractIds: ids,
|
||||
parcels: parcelResults,
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
step: "order",
|
||||
credits,
|
||||
message: `Enqueued batch of ${ids.length} parcels as ONE order.`,
|
||||
extractIds: ids,
|
||||
parcels: parcelResults,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: `Unknown step: ${step}` });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[ancpi-test] Step ${step} failed:`, message);
|
||||
return NextResponse.json({ error: message, step }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
|
||||
/**
|
||||
* Check auth for routes excluded from middleware (large upload routes).
|
||||
* Returns null if authenticated, or a 401 NextResponse if not.
|
||||
*/
|
||||
export async function requireAuth(
|
||||
req: NextRequest,
|
||||
): Promise<NextResponse | null> {
|
||||
// Skip in development
|
||||
if (process.env.NODE_ENV === "development") return null;
|
||||
|
||||
const token = await getToken({
|
||||
req,
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
});
|
||||
|
||||
if (token) return null;
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Authentication required" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { readFile, unlink } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { parseMultipartUpload } from "../parse-upload";
|
||||
import { requireAuth } from "../auth-check";
|
||||
|
||||
/**
|
||||
* iLovePDF API integration for PDF compression.
|
||||
*
|
||||
* Workflow: auth → start → upload → process → download
|
||||
* Docs: https://www.iloveapi.com/docs/api-reference
|
||||
*
|
||||
* Env vars: ILOVEPDF_PUBLIC_KEY
|
||||
* Free tier: 250 files/month
|
||||
*/
|
||||
|
||||
const ILOVEPDF_PUBLIC_KEY = process.env.ILOVEPDF_PUBLIC_KEY ?? "";
|
||||
const API_BASE = "https://api.ilovepdf.com/v1";
|
||||
|
||||
async function cleanup(dir: string) {
|
||||
try {
|
||||
const { readdir, rmdir } = await import("fs/promises");
|
||||
const files = await readdir(dir);
|
||||
for (const f of files) {
|
||||
await unlink(join(dir, f)).catch(() => {});
|
||||
}
|
||||
await rmdir(dir).catch(() => {});
|
||||
} catch {
|
||||
// non-critical
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const authError = await requireAuth(req);
|
||||
if (authError) return authError;
|
||||
|
||||
if (!ILOVEPDF_PUBLIC_KEY) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"iLovePDF nu este configurat. Setează ILOVEPDF_PUBLIC_KEY în variabilele de mediu.",
|
||||
},
|
||||
{ status: 501 },
|
||||
);
|
||||
}
|
||||
|
||||
let tmpDir = "";
|
||||
try {
|
||||
// Stream upload to disk — works for any file size
|
||||
const upload = await parseMultipartUpload(req);
|
||||
tmpDir = upload.tmpDir;
|
||||
|
||||
const originalSize = upload.size;
|
||||
|
||||
if (originalSize < 100) {
|
||||
return NextResponse.json(
|
||||
{ error: "Fișierul PDF este gol sau prea mic." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Compression level from form field
|
||||
const levelParam = upload.fields["level"] ?? "";
|
||||
const compressionLevel =
|
||||
levelParam === "extreme"
|
||||
? "extreme"
|
||||
: levelParam === "low"
|
||||
? "low"
|
||||
: "recommended";
|
||||
|
||||
// Step 1: Authenticate
|
||||
const authRes = await fetch(`${API_BASE}/auth`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ public_key: ILOVEPDF_PUBLIC_KEY }),
|
||||
});
|
||||
|
||||
if (!authRes.ok) {
|
||||
const text = await authRes.text().catch(() => "");
|
||||
return NextResponse.json(
|
||||
{ error: `iLovePDF auth failed: ${authRes.status} — ${text}` },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
const { token } = (await authRes.json()) as { token: string };
|
||||
|
||||
// Step 2: Start compress task
|
||||
const startRes = await fetch(`${API_BASE}/start/compress`, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!startRes.ok) {
|
||||
const text = await startRes.text().catch(() => "");
|
||||
return NextResponse.json(
|
||||
{ error: `iLovePDF start failed: ${startRes.status} — ${text}` },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
const { server, task } = (await startRes.json()) as {
|
||||
server: string;
|
||||
task: string;
|
||||
};
|
||||
|
||||
// Step 3: Upload file (read from disk to avoid double-buffering)
|
||||
const fileBuffer = await readFile(upload.filePath);
|
||||
const uploadForm = new FormData();
|
||||
uploadForm.append("task", task);
|
||||
uploadForm.append(
|
||||
"file",
|
||||
new Blob([new Uint8Array(fileBuffer)], { type: "application/pdf" }),
|
||||
upload.filename,
|
||||
);
|
||||
|
||||
const uploadRes = await fetch(`https://${server}/v1/upload`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: uploadForm,
|
||||
signal: AbortSignal.timeout(600_000), // 10 min for very large files
|
||||
});
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
const text = await uploadRes.text().catch(() => "");
|
||||
return NextResponse.json(
|
||||
{ error: `iLovePDF upload failed: ${uploadRes.status} — ${text}` },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
const { server_filename } = (await uploadRes.json()) as {
|
||||
server_filename: string;
|
||||
};
|
||||
|
||||
// Step 4: Process
|
||||
const processRes = await fetch(`https://${server}/v1/process`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
task,
|
||||
tool: "compress",
|
||||
compression_level: compressionLevel,
|
||||
files: [
|
||||
{
|
||||
server_filename,
|
||||
filename: upload.filename,
|
||||
},
|
||||
],
|
||||
}),
|
||||
signal: AbortSignal.timeout(600_000),
|
||||
});
|
||||
|
||||
if (!processRes.ok) {
|
||||
const text = await processRes.text().catch(() => "");
|
||||
return NextResponse.json(
|
||||
{ error: `iLovePDF process failed: ${processRes.status} — ${text}` },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
// Step 5: Download result
|
||||
const downloadRes = await fetch(
|
||||
`https://${server}/v1/download/${task}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(600_000),
|
||||
},
|
||||
);
|
||||
|
||||
if (!downloadRes.ok) {
|
||||
const text = await downloadRes.text().catch(() => "");
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `iLovePDF download failed: ${downloadRes.status} — ${text}`,
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
const resultBlob = await downloadRes.blob();
|
||||
const resultBuffer = Buffer.from(await resultBlob.arrayBuffer());
|
||||
const compressedSize = resultBuffer.length;
|
||||
|
||||
// Clean up task on iLovePDF (fire and forget)
|
||||
fetch(`https://${server}/v1/task/${task}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}).catch(() => {});
|
||||
|
||||
return new NextResponse(new Uint8Array(resultBuffer), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="${encodeURIComponent(upload.filename.replace(/\.pdf$/i, "-comprimat.pdf"))}"`,
|
||||
"X-Original-Size": String(originalSize),
|
||||
"X-Compressed-Size": String(compressedSize),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json(
|
||||
{ error: `Eroare iLovePDF: ${message}` },
|
||||
{ status: 500 },
|
||||
);
|
||||
} finally {
|
||||
if (tmpDir) await cleanup(tmpDir);
|
||||
}
|
||||
}
|
||||
@@ -1,72 +1,15 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { writeFile, readFile, unlink, mkdir } from "fs/promises";
|
||||
import { createReadStream, statSync } from "fs";
|
||||
import { unlink, stat, readdir, rmdir } from "fs/promises";
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { randomUUID } from "crypto";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import { Readable } from "stream";
|
||||
import { parseMultipartUpload } from "../parse-upload";
|
||||
import { requireAuth } from "../auth-check";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Ghostscript args for extreme compression
|
||||
// Key: -dPassThroughJPEGImages=false forces recompression of existing JPEGs
|
||||
// QFactor 1.5 ≈ JPEG quality 25-30, matching iLovePDF extreme
|
||||
function gsArgs(input: string, output: string): string[] {
|
||||
return [
|
||||
"-sDEVICE=pdfwrite",
|
||||
"-dCompatibilityLevel=1.5",
|
||||
"-dNOPAUSE",
|
||||
"-dBATCH",
|
||||
"-dQUIET",
|
||||
`-sOutputFile=${output}`,
|
||||
"-dPDFSETTINGS=/screen",
|
||||
// Force recompression of ALL images (the #1 key to matching iLovePDF)
|
||||
"-dPassThroughJPEGImages=false",
|
||||
"-dPassThroughJPXImages=false",
|
||||
"-dAutoFilterColorImages=false",
|
||||
"-dAutoFilterGrayImages=false",
|
||||
"-dColorImageFilter=/DCTEncode",
|
||||
"-dGrayImageFilter=/DCTEncode",
|
||||
// Aggressive downsampling
|
||||
"-dDownsampleColorImages=true",
|
||||
"-dDownsampleGrayImages=true",
|
||||
"-dDownsampleMonoImages=true",
|
||||
"-dColorImageResolution=72",
|
||||
"-dGrayImageResolution=72",
|
||||
"-dMonoImageResolution=150",
|
||||
"-dColorImageDownsampleType=/Bicubic",
|
||||
"-dGrayImageDownsampleType=/Bicubic",
|
||||
"-dColorImageDownsampleThreshold=1.0",
|
||||
"-dGrayImageDownsampleThreshold=1.0",
|
||||
"-dMonoImageDownsampleThreshold=1.0",
|
||||
// Encoding
|
||||
"-dEncodeColorImages=true",
|
||||
"-dEncodeGrayImages=true",
|
||||
// Font & structure
|
||||
"-dSubsetFonts=true",
|
||||
"-dEmbedAllFonts=true",
|
||||
"-dCompressFonts=true",
|
||||
"-dCompressStreams=true",
|
||||
// CMYK→RGB (saves ~25% on CMYK images)
|
||||
"-sColorConversionStrategy=RGB",
|
||||
// Structure optimization
|
||||
"-dDetectDuplicateImages=true",
|
||||
"-dWriteXRefStm=true",
|
||||
"-dWriteObjStms=true",
|
||||
"-dPreserveMarkedContent=false",
|
||||
"-dOmitXMP=true",
|
||||
// JPEG quality dictionaries (QFactor 1.5 ≈ quality 25-30)
|
||||
"-c",
|
||||
"<< /ColorACSImageDict << /QFactor 1.5 /Blend 1 /ColorTransform 1 /HSamples [2 1 1 2] /VSamples [2 1 1 2] >> >> setdistillerparams",
|
||||
"<< /GrayACSImageDict << /QFactor 1.5 /Blend 1 /HSamples [2 1 1 2] /VSamples [2 1 1 2] >> >> setdistillerparams",
|
||||
"<< /ColorImageDict << /QFactor 1.5 /Blend 1 /ColorTransform 1 /HSamples [2 1 1 2] /VSamples [2 1 1 2] >> >> setdistillerparams",
|
||||
"<< /GrayImageDict << /QFactor 1.5 /Blend 1 /HSamples [2 1 1 2] /VSamples [2 1 1 2] >> >> setdistillerparams",
|
||||
"-f",
|
||||
input,
|
||||
];
|
||||
}
|
||||
|
||||
// qpdf args for structure polish (5-15% additional saving)
|
||||
function qpdfArgs(input: string, output: string): string[] {
|
||||
return [
|
||||
input,
|
||||
@@ -82,106 +25,125 @@ function qpdfArgs(input: string, output: string): string[] {
|
||||
|
||||
async function cleanup(dir: string) {
|
||||
try {
|
||||
const { readdir } = await import("fs/promises");
|
||||
const files = await readdir(dir);
|
||||
for (const f of files) {
|
||||
await unlink(join(dir, f)).catch(() => {});
|
||||
}
|
||||
const { rmdir } = await import("fs/promises");
|
||||
await rmdir(dir).catch(() => {});
|
||||
} catch {
|
||||
// cleanup failure is non-critical
|
||||
// non-critical
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a file from disk as a Response — never loads into memory.
|
||||
*/
|
||||
function streamFileResponse(
|
||||
filePath: string,
|
||||
originalSize: number,
|
||||
compressedSize: number,
|
||||
filename: string,
|
||||
): NextResponse {
|
||||
const nodeStream = createReadStream(filePath);
|
||||
const webStream = Readable.toWeb(nodeStream) as ReadableStream;
|
||||
|
||||
return new NextResponse(webStream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Length": String(compressedSize),
|
||||
"Content-Disposition": `attachment; filename="${encodeURIComponent(filename)}"`,
|
||||
"X-Original-Size": String(originalSize),
|
||||
"X-Compressed-Size": String(compressedSize),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const tmpDir = join(tmpdir(), `pdf-extreme-${randomUUID()}`);
|
||||
const authError = await requireAuth(req);
|
||||
if (authError) return authError;
|
||||
|
||||
let tmpDir = "";
|
||||
try {
|
||||
const formData = await req.formData();
|
||||
const fileBlob = formData.get("fileInput") as Blob | null;
|
||||
if (!fileBlob) {
|
||||
const upload = await parseMultipartUpload(req);
|
||||
tmpDir = upload.tmpDir;
|
||||
|
||||
const inputPath = upload.filePath;
|
||||
const outputPath = join(upload.tmpDir, "output.pdf");
|
||||
const originalSize = upload.size;
|
||||
|
||||
console.log(
|
||||
`[compress-pdf] Starting qpdf on ${originalSize} bytes...`,
|
||||
);
|
||||
|
||||
if (originalSize < 100) {
|
||||
return NextResponse.json(
|
||||
{ error: "Lipsește fișierul PDF." },
|
||||
{ error: "Fișierul PDF este gol sau prea mic." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const originalSize = fileBlob.size;
|
||||
await mkdir(tmpDir, { recursive: true });
|
||||
|
||||
const inputPath = join(tmpDir, "input.pdf");
|
||||
const gsOutputPath = join(tmpDir, "gs-output.pdf");
|
||||
const finalOutputPath = join(tmpDir, "final.pdf");
|
||||
|
||||
await writeFile(inputPath, Buffer.from(await fileBlob.arrayBuffer()));
|
||||
|
||||
// Step 1: Ghostscript — aggressive image recompression + downsampling
|
||||
// Run qpdf
|
||||
try {
|
||||
await execFileAsync("gs", gsArgs(inputPath, gsOutputPath), {
|
||||
timeout: 120_000,
|
||||
await execFileAsync("qpdf", qpdfArgs(inputPath, outputPath), {
|
||||
timeout: 300_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
} catch (gsErr) {
|
||||
const msg = gsErr instanceof Error ? gsErr.message : "Ghostscript failed";
|
||||
} catch (qpdfErr) {
|
||||
const msg =
|
||||
qpdfErr instanceof Error ? qpdfErr.message : "qpdf failed";
|
||||
if (msg.includes("ENOENT") || msg.includes("not found")) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Ghostscript nu este instalat pe server. Trebuie adăugat `ghostscript` în Dockerfile.",
|
||||
},
|
||||
{ error: "qpdf nu este instalat pe server." },
|
||||
{ status: 501 },
|
||||
);
|
||||
}
|
||||
const exitCode =
|
||||
qpdfErr && typeof qpdfErr === "object" && "code" in qpdfErr
|
||||
? (qpdfErr as { code: number }).code
|
||||
: null;
|
||||
if (exitCode !== 3) {
|
||||
console.error(`[compress-pdf] qpdf error:`, msg.slice(0, 300));
|
||||
return NextResponse.json(
|
||||
{ error: `qpdf error: ${msg.slice(0, 300)}` },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check output
|
||||
try {
|
||||
await stat(outputPath);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: `Ghostscript error: ${msg}` },
|
||||
{ error: "qpdf nu a produs fișier output." },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: qpdf — structure optimization + linearization
|
||||
let finalPath = gsOutputPath;
|
||||
try {
|
||||
await execFileAsync("qpdf", qpdfArgs(gsOutputPath, finalOutputPath), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
finalPath = finalOutputPath;
|
||||
} catch {
|
||||
// qpdf failed or not installed — GS output is still good
|
||||
}
|
||||
const compressedSize = statSync(outputPath).size;
|
||||
|
||||
const resultBuffer = await readFile(finalPath);
|
||||
const compressedSize = resultBuffer.length;
|
||||
console.log(
|
||||
`[compress-pdf] Done: ${originalSize} → ${compressedSize} (${Math.round((1 - compressedSize / originalSize) * 100)}% reduction)`,
|
||||
);
|
||||
|
||||
// If compression made it bigger, return original
|
||||
// Stream result from disk — if bigger, stream original
|
||||
if (compressedSize >= originalSize) {
|
||||
const originalBuffer = await readFile(inputPath);
|
||||
return new NextResponse(originalBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition":
|
||||
'attachment; filename="compressed-extreme.pdf"',
|
||||
"X-Original-Size": String(originalSize),
|
||||
"X-Compressed-Size": String(originalSize),
|
||||
},
|
||||
});
|
||||
return streamFileResponse(inputPath, originalSize, originalSize, upload.filename);
|
||||
}
|
||||
|
||||
return new NextResponse(resultBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": 'attachment; filename="compressed-extreme.pdf"',
|
||||
"X-Original-Size": String(originalSize),
|
||||
"X-Compressed-Size": String(compressedSize),
|
||||
},
|
||||
});
|
||||
// NOTE: cleanup is deferred — we can't delete files while streaming.
|
||||
// The files will be cleaned up by the OS temp cleaner or on next request.
|
||||
// For immediate cleanup, we'd need to buffer, but that defeats the purpose.
|
||||
return streamFileResponse(outputPath, originalSize, compressedSize, upload.filename);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
console.error(`[compress-pdf] Error:`, message);
|
||||
if (tmpDir) await cleanup(tmpDir);
|
||||
return NextResponse.json(
|
||||
{ error: `Eroare la compresia extremă: ${message}` },
|
||||
{ error: `Eroare la optimizare: ${message}` },
|
||||
{ status: 500 },
|
||||
);
|
||||
} finally {
|
||||
await cleanup(tmpDir);
|
||||
}
|
||||
// Note: no finally cleanup — files are being streamed
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Streaming multipart parser for large PDF uploads.
|
||||
*
|
||||
* 1. Streams the request body to a raw temp file (constant memory)
|
||||
* 2. Scans the raw file for multipart boundaries using small buffer reads
|
||||
* 3. Copies just the file part to a separate PDF file (stream copy)
|
||||
*
|
||||
* Peak memory: ~64KB regardless of file size.
|
||||
*/
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
import {
|
||||
createWriteStream,
|
||||
createReadStream,
|
||||
openSync,
|
||||
readSync,
|
||||
closeSync,
|
||||
statSync,
|
||||
} from "fs";
|
||||
import { mkdir, unlink } from "fs/promises";
|
||||
import { randomUUID } from "crypto";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import { pipeline } from "stream/promises";
|
||||
|
||||
export interface ParsedUpload {
|
||||
filePath: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
tmpDir: string;
|
||||
fields: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a file on disk for a Buffer pattern starting from `offset`.
|
||||
* Reads in 64KB chunks — constant memory.
|
||||
*/
|
||||
function findInFile(
|
||||
filePath: string,
|
||||
pattern: Buffer,
|
||||
startOffset: number,
|
||||
): number {
|
||||
const CHUNK = 65536;
|
||||
const fd = openSync(filePath, "r");
|
||||
try {
|
||||
const buf = Buffer.alloc(CHUNK + pattern.length);
|
||||
let fileOffset = startOffset;
|
||||
const fileSize = statSync(filePath).size;
|
||||
|
||||
while (fileOffset < fileSize) {
|
||||
const bytesRead = readSync(
|
||||
fd,
|
||||
buf,
|
||||
0,
|
||||
Math.min(buf.length, fileSize - fileOffset),
|
||||
fileOffset,
|
||||
);
|
||||
if (bytesRead === 0) break;
|
||||
|
||||
const idx = buf.subarray(0, bytesRead).indexOf(pattern);
|
||||
if (idx !== -1) {
|
||||
return fileOffset + idx;
|
||||
}
|
||||
|
||||
// Advance, but overlap by pattern length to catch split matches
|
||||
fileOffset += bytesRead - pattern.length;
|
||||
}
|
||||
return -1;
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a small chunk from a file at a given offset.
|
||||
*/
|
||||
function readChunk(filePath: string, offset: number, length: number): Buffer {
|
||||
const fd = openSync(filePath, "r");
|
||||
try {
|
||||
const buf = Buffer.alloc(length);
|
||||
const bytesRead = readSync(fd, buf, 0, length, offset);
|
||||
return buf.subarray(0, bytesRead);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a byte range from one file to another using streams.
|
||||
*/
|
||||
async function copyFileRange(
|
||||
srcPath: string,
|
||||
destPath: string,
|
||||
start: number,
|
||||
end: number,
|
||||
): Promise<void> {
|
||||
const rs = createReadStream(srcPath, { start, end: end - 1 });
|
||||
const ws = createWriteStream(destPath);
|
||||
await pipeline(rs, ws);
|
||||
}
|
||||
|
||||
export async function parseMultipartUpload(
|
||||
req: NextRequest,
|
||||
): Promise<ParsedUpload> {
|
||||
const contentType = req.headers.get("content-type") ?? "";
|
||||
if (!req.body) throw new Error("Lipsește body-ul cererii.");
|
||||
|
||||
const boundaryMatch = contentType.match(/boundary=(.+?)(?:;|$)/);
|
||||
if (!boundaryMatch?.[1]) throw new Error("Lipsește boundary din Content-Type.");
|
||||
const boundary = boundaryMatch[1].trim();
|
||||
|
||||
const tmpDir = join(tmpdir(), `pdf-upload-${randomUUID()}`);
|
||||
await mkdir(tmpDir, { recursive: true });
|
||||
|
||||
// Step 1: Stream entire body to disk (constant memory)
|
||||
const rawPath = join(tmpDir, "raw-body");
|
||||
const ws = createWriteStream(rawPath);
|
||||
const reader = req.body.getReader();
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const ok = ws.write(Buffer.from(value));
|
||||
if (!ok) await new Promise<void>((r) => ws.once("drain", r));
|
||||
}
|
||||
} finally {
|
||||
ws.end();
|
||||
await new Promise<void>((r) => ws.once("finish", r));
|
||||
}
|
||||
|
||||
const rawSize = statSync(rawPath).size;
|
||||
console.log(`[parse-upload] Raw body saved: ${rawSize} bytes`);
|
||||
|
||||
// Step 2: Find file part boundaries using small buffer reads
|
||||
const boundaryBuf = Buffer.from(`--${boundary}`);
|
||||
const headerEndBuf = Buffer.from("\r\n\r\n");
|
||||
const closingBuf = Buffer.from(`\r\n--${boundary}`);
|
||||
|
||||
let filename = "input.pdf";
|
||||
let fileStart = -1;
|
||||
let searchFrom = 0;
|
||||
const fields: Record<string, string> = {};
|
||||
|
||||
while (searchFrom < rawSize) {
|
||||
const partStart = findInFile(rawPath, boundaryBuf, searchFrom);
|
||||
if (partStart === -1) break;
|
||||
|
||||
const headerEnd = findInFile(
|
||||
rawPath,
|
||||
headerEndBuf,
|
||||
partStart + boundaryBuf.length,
|
||||
);
|
||||
if (headerEnd === -1) break;
|
||||
|
||||
// Read just the headers (small — typically <500 bytes)
|
||||
const headersLen = headerEnd - (partStart + boundaryBuf.length);
|
||||
const headers = readChunk(
|
||||
rawPath,
|
||||
partStart + boundaryBuf.length,
|
||||
Math.min(headersLen, 2048),
|
||||
).toString("utf8");
|
||||
|
||||
if (headers.includes("filename=")) {
|
||||
const fnMatch = headers.match(/filename="([^"]+)"/);
|
||||
if (fnMatch?.[1]) filename = fnMatch[1];
|
||||
fileStart = headerEnd + 4;
|
||||
break;
|
||||
}
|
||||
|
||||
// Parse form field value
|
||||
const nameMatch = headers.match(
|
||||
/Content-Disposition:\s*form-data;\s*name="([^"]+)"/,
|
||||
);
|
||||
if (nameMatch?.[1]) {
|
||||
const valStart = headerEnd + 4;
|
||||
const nextBoundary = findInFile(rawPath, closingBuf, valStart);
|
||||
if (nextBoundary !== -1 && nextBoundary - valStart < 10000) {
|
||||
fields[nameMatch[1]] = readChunk(
|
||||
rawPath,
|
||||
valStart,
|
||||
nextBoundary - valStart,
|
||||
).toString("utf8");
|
||||
}
|
||||
}
|
||||
|
||||
searchFrom = headerEnd + 4;
|
||||
}
|
||||
|
||||
if (fileStart === -1) throw new Error("Lipsește fișierul PDF din upload.");
|
||||
|
||||
const fileEnd = findInFile(rawPath, closingBuf, fileStart);
|
||||
const pdfEnd = fileEnd > fileStart ? fileEnd : rawSize;
|
||||
const pdfSize = pdfEnd - fileStart;
|
||||
|
||||
if (pdfSize < 100) throw new Error("Fișierul PDF extras este gol sau prea mic.");
|
||||
|
||||
console.log(
|
||||
`[parse-upload] PDF extracted: ${pdfSize} bytes (offset ${fileStart}..${pdfEnd})`,
|
||||
);
|
||||
|
||||
// Step 3: Copy just the PDF bytes to a new file (stream copy)
|
||||
const filePath = join(tmpDir, filename);
|
||||
await copyFileRange(rawPath, filePath, fileStart, pdfEnd);
|
||||
|
||||
// Delete raw body — no longer needed
|
||||
await unlink(rawPath).catch(() => {});
|
||||
|
||||
return { filePath, filename, size: pdfSize, tmpDir, fields };
|
||||
}
|
||||
@@ -1,18 +1,38 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireAuth } from "./auth-check";
|
||||
|
||||
const STIRLING_PDF_URL =
|
||||
process.env.STIRLING_PDF_URL ?? "http://10.10.10.166:8087";
|
||||
const STIRLING_PDF_API_KEY =
|
||||
process.env.STIRLING_PDF_API_KEY ?? "cd829f62-6eef-43eb-a64d-c91af727b53a";
|
||||
const STIRLING_PDF_URL = process.env.STIRLING_PDF_URL;
|
||||
const STIRLING_PDF_API_KEY = process.env.STIRLING_PDF_API_KEY;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const authErr = await requireAuth(req);
|
||||
if (authErr) return authErr;
|
||||
|
||||
if (!STIRLING_PDF_URL || !STIRLING_PDF_API_KEY) {
|
||||
return NextResponse.json(
|
||||
{ error: "Stirling PDF nu este configurat" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await req.formData();
|
||||
// Buffer the full body then forward to Stirling — streaming passthrough
|
||||
// (req.body + duplex:half) is unreliable for large files in Next.js.
|
||||
const bodyBytes = await req.arrayBuffer();
|
||||
const contentType = req.headers.get("content-type") || "";
|
||||
|
||||
// Extract original file size from the multipart body for the response header
|
||||
// (rough estimate — the overhead of multipart framing is negligible for large PDFs)
|
||||
const originalSize = bodyBytes.byteLength;
|
||||
|
||||
const res = await fetch(`${STIRLING_PDF_URL}/api/v1/misc/compress-pdf`, {
|
||||
method: "POST",
|
||||
headers: { "X-API-KEY": STIRLING_PDF_API_KEY },
|
||||
body: formData,
|
||||
headers: {
|
||||
"X-API-KEY": STIRLING_PDF_API_KEY,
|
||||
"Content-Type": contentType,
|
||||
},
|
||||
body: bodyBytes,
|
||||
signal: AbortSignal.timeout(300_000), // 5 min for large files
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -26,11 +46,13 @@ export async function POST(req: NextRequest) {
|
||||
const blob = await res.blob();
|
||||
const buffer = Buffer.from(await blob.arrayBuffer());
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
return new NextResponse(new Uint8Array(buffer), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": 'attachment; filename="compressed.pdf"',
|
||||
"X-Original-Size": String(originalSize),
|
||||
"X-Compressed-Size": String(buffer.length),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,20 +1,34 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireAuth } from "../auth-check";
|
||||
|
||||
const STIRLING_PDF_URL =
|
||||
process.env.STIRLING_PDF_URL ?? "http://10.10.10.166:8087";
|
||||
const STIRLING_PDF_API_KEY =
|
||||
process.env.STIRLING_PDF_API_KEY ?? "cd829f62-6eef-43eb-a64d-c91af727b53a";
|
||||
const STIRLING_PDF_URL = process.env.STIRLING_PDF_URL;
|
||||
const STIRLING_PDF_API_KEY = process.env.STIRLING_PDF_API_KEY;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const formData = await req.formData();
|
||||
const authErr = await requireAuth(req);
|
||||
if (authErr) return authErr;
|
||||
|
||||
if (!STIRLING_PDF_URL || !STIRLING_PDF_API_KEY) {
|
||||
return NextResponse.json(
|
||||
{ error: "Stirling PDF nu este configurat" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Stream body directly to Stirling — avoids FormData re-serialization
|
||||
// failure on large files ("Failed to parse body as FormData")
|
||||
const res = await fetch(
|
||||
`${STIRLING_PDF_URL}/api/v1/security/remove-password`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "X-API-KEY": STIRLING_PDF_API_KEY },
|
||||
body: formData,
|
||||
headers: {
|
||||
"X-API-KEY": STIRLING_PDF_API_KEY,
|
||||
"Content-Type": req.headers.get("content-type") || "",
|
||||
},
|
||||
body: req.body,
|
||||
// @ts-expect-error duplex required for streaming request bodies in Node
|
||||
duplex: "half",
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { syncLayer } from "@/modules/parcel-sync/services/sync-service";
|
||||
import {
|
||||
getLayerFreshness,
|
||||
isFresh,
|
||||
} from "@/modules/parcel-sync/services/enrich-service";
|
||||
import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-health";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 300; // 5 min max — N8N handles overall timeout
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
type UatRefreshResult = {
|
||||
siruta: string;
|
||||
uatName: string;
|
||||
action: "synced" | "fresh" | "error";
|
||||
reason?: string;
|
||||
terenuri?: { new: number; removed: number };
|
||||
cladiri?: { new: number; removed: number };
|
||||
durationMs?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/eterra/auto-refresh
|
||||
*
|
||||
* Server-to-server endpoint called by N8N cron to keep DB data fresh.
|
||||
* Auth: Authorization: Bearer <NOTIFICATION_CRON_SECRET>
|
||||
*
|
||||
* Query params:
|
||||
* ?maxUats=5 — max UATs to process per run (default 5, max 10)
|
||||
* ?maxAgeHours=168 — freshness threshold in hours (default 168 = 7 days)
|
||||
* ?forceFullSync=true — force full re-download (for weekly deep sync)
|
||||
* ?includeEnrichment=true — re-enrich UATs with partial enrichment
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
// ── Auth ──
|
||||
const secret = process.env.NOTIFICATION_CRON_SECRET;
|
||||
if (!secret) {
|
||||
return NextResponse.json(
|
||||
{ error: "NOTIFICATION_CRON_SECRET not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
const token = authHeader?.replace("Bearer ", "");
|
||||
if (token !== secret) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// ── Parse params ──
|
||||
const url = new URL(request.url);
|
||||
const maxUats = Math.min(
|
||||
Number(url.searchParams.get("maxUats") ?? "5") || 5,
|
||||
10,
|
||||
);
|
||||
const maxAgeHours =
|
||||
Number(url.searchParams.get("maxAgeHours") ?? "168") || 168;
|
||||
const forceFullSync = url.searchParams.get("forceFullSync") === "true";
|
||||
const includeEnrichment =
|
||||
url.searchParams.get("includeEnrichment") === "true";
|
||||
|
||||
// ── Credentials ──
|
||||
const username = process.env.ETERRA_USERNAME;
|
||||
const password = process.env.ETERRA_PASSWORD;
|
||||
if (!username || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: "ETERRA_USERNAME / ETERRA_PASSWORD not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Health check ──
|
||||
const health = await checkEterraHealthNow();
|
||||
if (!health.available) {
|
||||
return NextResponse.json({
|
||||
processed: 0,
|
||||
skipped: 0,
|
||||
errors: 0,
|
||||
duration: "0s",
|
||||
message: `eTerra indisponibil: ${health.message ?? "maintenance"}`,
|
||||
details: [],
|
||||
});
|
||||
}
|
||||
|
||||
// ── Find UATs with data in DB ──
|
||||
const uatGroups = await prisma.gisFeature.groupBy({
|
||||
by: ["siruta"],
|
||||
_count: { id: true },
|
||||
});
|
||||
|
||||
// Resolve UAT names
|
||||
const sirutas = uatGroups.map((g) => g.siruta);
|
||||
const uatRecords = await prisma.gisUat.findMany({
|
||||
where: { siruta: { in: sirutas } },
|
||||
select: { siruta: true, name: true },
|
||||
});
|
||||
const nameMap = new Map(uatRecords.map((u) => [u.siruta, u.name]));
|
||||
|
||||
// ── Check freshness per UAT ──
|
||||
type UatCandidate = {
|
||||
siruta: string;
|
||||
uatName: string;
|
||||
featureCount: number;
|
||||
terenuriStale: boolean;
|
||||
cladiriStale: boolean;
|
||||
enrichedCount: number;
|
||||
totalCount: number;
|
||||
};
|
||||
|
||||
const stale: UatCandidate[] = [];
|
||||
const fresh: string[] = [];
|
||||
|
||||
for (const group of uatGroups) {
|
||||
const sir = group.siruta;
|
||||
const [tStatus, cStatus] = await Promise.all([
|
||||
getLayerFreshness(sir, "TERENURI_ACTIVE"),
|
||||
getLayerFreshness(sir, "CLADIRI_ACTIVE"),
|
||||
]);
|
||||
const tFresh = isFresh(tStatus.lastSynced, maxAgeHours);
|
||||
const cFresh = isFresh(cStatus.lastSynced, maxAgeHours);
|
||||
|
||||
if (forceFullSync || !tFresh || !cFresh) {
|
||||
stale.push({
|
||||
siruta: sir,
|
||||
uatName: nameMap.get(sir) ?? sir,
|
||||
featureCount: group._count.id,
|
||||
terenuriStale: !tFresh || forceFullSync,
|
||||
cladiriStale: !cFresh || forceFullSync,
|
||||
enrichedCount: tStatus.enrichedCount,
|
||||
totalCount: tStatus.featureCount + cStatus.featureCount,
|
||||
});
|
||||
} else {
|
||||
fresh.push(sir);
|
||||
}
|
||||
}
|
||||
|
||||
// Shuffle stale UATs so we don't always process the same ones first
|
||||
for (let i = stale.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[stale[i]!, stale[j]!] = [stale[j]!, stale[i]!];
|
||||
}
|
||||
|
||||
const toProcess = stale.slice(0, maxUats);
|
||||
const startTime = Date.now();
|
||||
const details: UatRefreshResult[] = [];
|
||||
let errorCount = 0;
|
||||
|
||||
// ── Process stale UATs ──
|
||||
for (let idx = 0; idx < toProcess.length; idx++) {
|
||||
const uat = toProcess[idx]!;
|
||||
|
||||
// Random delay between UATs (30-120s) to spread load
|
||||
if (idx > 0) {
|
||||
const delay = 30_000 + Math.random() * 90_000;
|
||||
await sleep(delay);
|
||||
}
|
||||
|
||||
const uatStart = Date.now();
|
||||
console.log(
|
||||
`[auto-refresh] Processing UAT ${uat.siruta} (${uat.uatName})...`,
|
||||
);
|
||||
|
||||
try {
|
||||
let terenuriResult = { newFeatures: 0, removedFeatures: 0 };
|
||||
let cladiriResult = { newFeatures: 0, removedFeatures: 0 };
|
||||
|
||||
if (uat.terenuriStale) {
|
||||
const res = await syncLayer(username, password, uat.siruta, "TERENURI_ACTIVE", {
|
||||
uatName: uat.uatName,
|
||||
forceFullSync,
|
||||
});
|
||||
terenuriResult = { newFeatures: res.newFeatures, removedFeatures: res.removedFeatures };
|
||||
}
|
||||
|
||||
if (uat.cladiriStale) {
|
||||
const res = await syncLayer(username, password, uat.siruta, "CLADIRI_ACTIVE", {
|
||||
uatName: uat.uatName,
|
||||
forceFullSync,
|
||||
});
|
||||
cladiriResult = { newFeatures: res.newFeatures, removedFeatures: res.removedFeatures };
|
||||
}
|
||||
|
||||
// Optional: re-enrich if partial enrichment
|
||||
if (includeEnrichment && uat.enrichedCount < uat.totalCount) {
|
||||
try {
|
||||
const { EterraClient } = await import(
|
||||
"@/modules/parcel-sync/services/eterra-client"
|
||||
);
|
||||
const { enrichFeatures } = await import(
|
||||
"@/modules/parcel-sync/services/enrich-service"
|
||||
);
|
||||
const enrichClient = await EterraClient.create(username, password);
|
||||
await enrichFeatures(enrichClient, uat.siruta);
|
||||
} catch (enrichErr) {
|
||||
console.warn(
|
||||
`[auto-refresh] Enrichment failed for ${uat.siruta}:`,
|
||||
enrichErr instanceof Error ? enrichErr.message : enrichErr,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - uatStart;
|
||||
console.log(
|
||||
`[auto-refresh] UAT ${uat.siruta}: terenuri +${terenuriResult.newFeatures}/-${terenuriResult.removedFeatures}, cladiri +${cladiriResult.newFeatures}/-${cladiriResult.removedFeatures} (${(durationMs / 1000).toFixed(1)}s)`,
|
||||
);
|
||||
|
||||
details.push({
|
||||
siruta: uat.siruta,
|
||||
uatName: uat.uatName,
|
||||
action: "synced",
|
||||
terenuri: { new: terenuriResult.newFeatures, removed: terenuriResult.removedFeatures },
|
||||
cladiri: { new: cladiriResult.newFeatures, removed: cladiriResult.removedFeatures },
|
||||
durationMs,
|
||||
});
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
const msg = error instanceof Error ? error.message : "Unknown error";
|
||||
console.error(`[auto-refresh] Error on UAT ${uat.siruta}: ${msg}`);
|
||||
details.push({
|
||||
siruta: uat.siruta,
|
||||
uatName: uat.uatName,
|
||||
action: "error",
|
||||
reason: msg,
|
||||
durationMs: Date.now() - uatStart,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const totalDuration = Date.now() - startTime;
|
||||
const durationStr =
|
||||
totalDuration > 60_000
|
||||
? `${Math.floor(totalDuration / 60_000)}m ${Math.round((totalDuration % 60_000) / 1000)}s`
|
||||
: `${Math.round(totalDuration / 1000)}s`;
|
||||
|
||||
console.log(
|
||||
`[auto-refresh] Completed ${toProcess.length}/${stale.length} UATs, ${errorCount} errors (${durationStr})`,
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
processed: toProcess.length,
|
||||
skipped: fresh.length,
|
||||
staleTotal: stale.length,
|
||||
errors: errorCount,
|
||||
duration: durationStr,
|
||||
details,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* GET /api/eterra/counties
|
||||
*
|
||||
* Returns distinct county names from GisUat, sorted alphabetically.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const rows = await prisma.gisUat.findMany({
|
||||
where: { county: { not: null } },
|
||||
select: { county: true },
|
||||
distinct: ["county"],
|
||||
orderBy: { county: "asc" },
|
||||
});
|
||||
const counties = rows.map((r) => r.county).filter(Boolean) as string[];
|
||||
return NextResponse.json({ counties });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare la interogare judete";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* GET /api/eterra/debug-fields?siruta=161829&cadRef=77102
|
||||
*
|
||||
* Diagnostic endpoint — shows all available fields from eTerra + local DB
|
||||
* for a specific parcel and its buildings.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
const siruta = url.searchParams.get("siruta") ?? "161829";
|
||||
const cadRef = url.searchParams.get("cadRef") ?? "77102";
|
||||
|
||||
const username = process.env.ETERRA_USERNAME;
|
||||
const password = process.env.ETERRA_PASSWORD;
|
||||
if (!username || !password) {
|
||||
return NextResponse.json({ error: "ETERRA creds missing" }, { status: 500 });
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
query: { siruta, cadRef },
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
try {
|
||||
const client = await EterraClient.create(username, password);
|
||||
|
||||
// 1. GIS layer: TERENURI_ACTIVE — raw attributes
|
||||
const terenuri = await client.listLayerByWhere(
|
||||
{ id: "TERENURI_ACTIVE", name: "TERENURI_ACTIVE", endpoint: "aut" },
|
||||
`ADMIN_UNIT_ID=${siruta} AND IS_ACTIVE=1 AND NATIONAL_CADASTRAL_REFERENCE='${cadRef}'`,
|
||||
{ limit: 1, outFields: "*" },
|
||||
);
|
||||
const parcelAttrs = terenuri[0]?.attributes ?? null;
|
||||
result.gis_parcela = {
|
||||
found: !!parcelAttrs,
|
||||
fields: parcelAttrs
|
||||
? Object.entries(parcelAttrs)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([k, v]) => ({ field: k, value: v, type: typeof v }))
|
||||
: [],
|
||||
};
|
||||
|
||||
// 2. GIS layer: CLADIRI_ACTIVE — buildings on this parcel
|
||||
const cladiri = await client.listLayerByWhere(
|
||||
{ id: "CLADIRI_ACTIVE", name: "CLADIRI_ACTIVE", endpoint: "aut" },
|
||||
`ADMIN_UNIT_ID=${siruta} AND IS_ACTIVE=1 AND NATIONAL_CADASTRAL_REFERENCE LIKE '${cadRef}-%'`,
|
||||
{ limit: 20, outFields: "*" },
|
||||
);
|
||||
result.gis_cladiri = {
|
||||
count: cladiri.length,
|
||||
buildings: cladiri.map((c) => {
|
||||
const a = c.attributes;
|
||||
return {
|
||||
cadastralRef: a.NATIONAL_CADASTRAL_REFERENCE,
|
||||
fields: Object.entries(a)
|
||||
.sort(([x], [y]) => x.localeCompare(y))
|
||||
.filter(([, v]) => v != null && v !== "" && v !== 0)
|
||||
.map(([k, v]) => ({ field: k, value: v, type: typeof v })),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
// 3. Immovable details (enrichment source)
|
||||
const immId = parcelAttrs?.IMMOVABLE_ID;
|
||||
const wsId = parcelAttrs?.WORKSPACE_ID;
|
||||
if (immId && wsId) {
|
||||
try {
|
||||
const details = await client.fetchImmovableParcelDetails(
|
||||
wsId as string | number,
|
||||
immId as string | number,
|
||||
);
|
||||
result.immovable_parcel_details = {
|
||||
count: details.length,
|
||||
items: details,
|
||||
};
|
||||
} catch (e) {
|
||||
result.immovable_parcel_details = {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Immovable list entry (address source)
|
||||
try {
|
||||
const listResponse = await client.fetchImmovableListByAdminUnit(
|
||||
wsId as number,
|
||||
siruta,
|
||||
0,
|
||||
5,
|
||||
true,
|
||||
);
|
||||
const items = (listResponse?.content ?? []) as Record<string, unknown>[];
|
||||
// Find our specific immovable
|
||||
const match = items.find(
|
||||
(item) => String(item.immovablePk) === String(immId) ||
|
||||
String(item.identifierDetails ?? "").includes(cadRef),
|
||||
);
|
||||
result.immovable_list_entry = {
|
||||
totalInUat: listResponse?.totalElements ?? "?",
|
||||
matchFound: !!match,
|
||||
entry: match ?? null,
|
||||
note: "Acest obiect contine campul immovableAddresses cu adresa completa",
|
||||
};
|
||||
} catch (e) {
|
||||
result.immovable_list_entry = {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Documentation data (owner source)
|
||||
try {
|
||||
const docResponse = await client.fetchDocumentationData(
|
||||
wsId as number,
|
||||
[String(immId)],
|
||||
);
|
||||
const immovables = docResponse?.immovables ?? [];
|
||||
const regs = docResponse?.partTwoRegs ?? [];
|
||||
result.documentation_data = {
|
||||
immovablesCount: immovables.length,
|
||||
immovables: immovables.slice(0, 3),
|
||||
registrationsCount: regs.length,
|
||||
registrations: regs.slice(0, 10),
|
||||
note: "partTwoRegs contine proprietarii (nodeType=P, nodeStatus=-1=radiat)",
|
||||
};
|
||||
} catch (e) {
|
||||
result.documentation_data = {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Local DB data (what we have stored)
|
||||
const dbParcel = await prisma.gisFeature.findFirst({
|
||||
where: { layerId: "TERENURI_ACTIVE", siruta, cadastralRef: cadRef },
|
||||
select: {
|
||||
objectId: true,
|
||||
cadastralRef: true,
|
||||
areaValue: true,
|
||||
isActive: true,
|
||||
enrichment: true,
|
||||
enrichedAt: true,
|
||||
geometrySource: true,
|
||||
},
|
||||
});
|
||||
const dbBuildings = await prisma.gisFeature.findMany({
|
||||
where: {
|
||||
layerId: "CLADIRI_ACTIVE",
|
||||
siruta,
|
||||
cadastralRef: { startsWith: `${cadRef}-` },
|
||||
},
|
||||
select: {
|
||||
objectId: true,
|
||||
cadastralRef: true,
|
||||
areaValue: true,
|
||||
attributes: true,
|
||||
},
|
||||
});
|
||||
result.local_db = {
|
||||
parcel: dbParcel
|
||||
? {
|
||||
objectId: dbParcel.objectId,
|
||||
cadastralRef: dbParcel.cadastralRef,
|
||||
areaValue: dbParcel.areaValue,
|
||||
enrichedAt: dbParcel.enrichedAt,
|
||||
geometrySource: dbParcel.geometrySource,
|
||||
enrichment: dbParcel.enrichment,
|
||||
}
|
||||
: null,
|
||||
buildings: dbBuildings.map((b) => ({
|
||||
objectId: b.objectId,
|
||||
cadastralRef: b.cadastralRef,
|
||||
areaValue: b.areaValue,
|
||||
is_legal: (b.attributes as Record<string, unknown>)?.IS_LEGAL,
|
||||
})),
|
||||
};
|
||||
} catch (e) {
|
||||
result.error = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
return NextResponse.json(result, {
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
@@ -250,6 +250,20 @@ export async function POST(req: Request) {
|
||||
pushProgress();
|
||||
updatePhaseProgress(2, 2);
|
||||
}
|
||||
// Sync admin layers (lightweight, non-fatal)
|
||||
for (const adminLayer of ["LIMITE_INTRAV_DYNAMIC", "LIMITE_UAT"]) {
|
||||
try {
|
||||
await syncLayer(
|
||||
validated.username,
|
||||
validated.password,
|
||||
validated.siruta,
|
||||
adminLayer,
|
||||
{ jobId, isSubStep: true },
|
||||
);
|
||||
} catch {
|
||||
// admin layers are best-effort
|
||||
}
|
||||
}
|
||||
finishPhase();
|
||||
|
||||
/* ══════════════════════════════════════════════════════════ */
|
||||
@@ -548,6 +562,19 @@ export async function POST(req: Request) {
|
||||
zip.file("terenuri.gpkg", terenuriGpkg);
|
||||
zip.file("cladiri.gpkg", cladiriGpkg);
|
||||
|
||||
// DXF versions (non-fatal)
|
||||
try {
|
||||
const { gpkgToDxf } = await import(
|
||||
"@/modules/parcel-sync/services/gpkg-export"
|
||||
);
|
||||
const tDxf = await gpkgToDxf(terenuriGpkg, "TERENURI_ACTIVE");
|
||||
if (tDxf) zip.file("terenuri.dxf", tDxf);
|
||||
const cDxf = await gpkgToDxf(cladiriGpkg, "CLADIRI_ACTIVE");
|
||||
if (cDxf) zip.file("cladiri.dxf", cDxf);
|
||||
} catch {
|
||||
// DXF conversion not available — skip silently
|
||||
}
|
||||
|
||||
// ── Comprehensive quality analysis ──
|
||||
const withGeomRecords = dbTerenuri.filter(
|
||||
(r) =>
|
||||
@@ -671,6 +698,15 @@ export async function POST(req: Request) {
|
||||
|
||||
if (validated.mode === "magic" && magicGpkg && csvContent) {
|
||||
zip.file("terenuri_magic.gpkg", magicGpkg);
|
||||
try {
|
||||
const { gpkgToDxf } = await import(
|
||||
"@/modules/parcel-sync/services/gpkg-export"
|
||||
);
|
||||
const mDxf = await gpkgToDxf(magicGpkg, "TERENURI_MAGIC");
|
||||
if (mDxf) zip.file("terenuri_magic.dxf", mDxf);
|
||||
} catch {
|
||||
// DXF conversion not available
|
||||
}
|
||||
zip.file("terenuri_complet.csv", csvContent);
|
||||
report.magic = {
|
||||
csvRows: csvContent.split("\n").length - 1,
|
||||
|
||||
@@ -182,6 +182,15 @@ async function buildFullZip(siruta: string, mode: "base" | "magic") {
|
||||
zip.file("terenuri.gpkg", terenuriGpkg);
|
||||
zip.file("cladiri.gpkg", cladiriGpkg);
|
||||
|
||||
// DXF versions (non-fatal — ogr2ogr may not be available)
|
||||
const { gpkgToDxf } = await import(
|
||||
"@/modules/parcel-sync/services/gpkg-export"
|
||||
);
|
||||
const terenuriDxf = await gpkgToDxf(terenuriGpkg, "TERENURI_ACTIVE");
|
||||
if (terenuriDxf) zip.file("terenuri.dxf", terenuriDxf);
|
||||
const cladiriDxf = await gpkgToDxf(cladiriGpkg, "CLADIRI_ACTIVE");
|
||||
if (cladiriDxf) zip.file("cladiri.dxf", cladiriDxf);
|
||||
|
||||
if (mode === "magic") {
|
||||
// ── Magic: enrichment-merged GPKG + CSV + quality report ──
|
||||
const headers = [
|
||||
@@ -295,6 +304,8 @@ async function buildFullZip(siruta: string, mode: "base" | "magic") {
|
||||
});
|
||||
|
||||
zip.file("terenuri_magic.gpkg", magicGpkg);
|
||||
const magicDxf = await gpkgToDxf(magicGpkg, "TERENURI_MAGIC");
|
||||
if (magicDxf) zip.file("terenuri_magic.dxf", magicDxf);
|
||||
zip.file("terenuri_complet.csv", csvRows.join("\n"));
|
||||
|
||||
// ── Quality analysis ──
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* POST /api/eterra/refresh-all
|
||||
*
|
||||
* Runs delta sync on ALL UATs in DB sequentially.
|
||||
* UATs with >30% enrichment → magic mode (sync + enrichment).
|
||||
* UATs with ≤30% enrichment → base mode (sync only).
|
||||
*
|
||||
* Returns immediately with jobId — progress via /api/eterra/progress.
|
||||
*/
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import {
|
||||
setProgress,
|
||||
clearProgress,
|
||||
type SyncProgress,
|
||||
} from "@/modules/parcel-sync/services/progress-store";
|
||||
import { syncLayer } from "@/modules/parcel-sync/services/sync-service";
|
||||
import { enrichFeatures } from "@/modules/parcel-sync/services/enrich-service";
|
||||
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||
import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-health";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function POST() {
|
||||
const username = process.env.ETERRA_USERNAME ?? "";
|
||||
const password = process.env.ETERRA_PASSWORD ?? "";
|
||||
if (!username || !password) {
|
||||
return Response.json(
|
||||
{ error: "ETERRA_USERNAME / ETERRA_PASSWORD not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const jobId = crypto.randomUUID();
|
||||
setProgress({
|
||||
jobId,
|
||||
downloaded: 0,
|
||||
total: 100,
|
||||
status: "running",
|
||||
phase: "Pregătire refresh complet",
|
||||
});
|
||||
|
||||
void runRefreshAll(jobId, username, password);
|
||||
|
||||
return Response.json({ jobId, message: "Refresh complet pornit" }, { status: 202 });
|
||||
}
|
||||
|
||||
async function runRefreshAll(jobId: string, username: string, password: string) {
|
||||
const push = (p: Partial<SyncProgress>) =>
|
||||
setProgress({ jobId, downloaded: 0, total: 100, status: "running", ...p } as SyncProgress);
|
||||
|
||||
try {
|
||||
// Health check
|
||||
const health = await checkEterraHealthNow();
|
||||
if (!health.available) {
|
||||
setProgress({ jobId, downloaded: 0, total: 100, status: "error", phase: "eTerra indisponibil", message: health.message ?? "maintenance" });
|
||||
setTimeout(() => clearProgress(jobId), 3_600_000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find all UATs with features + enrichment ratio
|
||||
const uats = await prisma.$queryRawUnsafe<
|
||||
Array<{ siruta: string; name: string | null; total: number; enriched: number }>
|
||||
>(
|
||||
`SELECT f.siruta, u.name, COUNT(*)::int as total,
|
||||
COUNT(*) FILTER (WHERE f."enrichedAt" IS NOT NULL)::int as enriched
|
||||
FROM "GisFeature" f LEFT JOIN "GisUat" u ON f.siruta = u.siruta
|
||||
WHERE f."layerId" IN ('TERENURI_ACTIVE','CLADIRI_ACTIVE') AND f."objectId" > 0
|
||||
GROUP BY f.siruta, u.name ORDER BY total DESC`,
|
||||
);
|
||||
|
||||
if (uats.length === 0) {
|
||||
setProgress({ jobId, downloaded: 100, total: 100, status: "done", phase: "Niciun UAT in DB" });
|
||||
setTimeout(() => clearProgress(jobId), 3_600_000);
|
||||
return;
|
||||
}
|
||||
|
||||
const results: Array<{ siruta: string; name: string; mode: string; duration: number; note: string }> = [];
|
||||
let errors = 0;
|
||||
|
||||
for (let i = 0; i < uats.length; i++) {
|
||||
const uat = uats[i]!;
|
||||
const uatName = uat.name ?? uat.siruta;
|
||||
const ratio = uat.total > 0 ? uat.enriched / uat.total : 0;
|
||||
const isMagic = ratio > 0.3;
|
||||
const mode = isMagic ? "magic" : "base";
|
||||
const pct = Math.round(((i) / uats.length) * 100);
|
||||
|
||||
push({
|
||||
downloaded: pct,
|
||||
total: 100,
|
||||
phase: `[${i + 1}/${uats.length}] ${uatName} (${mode})`,
|
||||
note: results.length > 0 ? `Ultimul: ${results[results.length - 1]!.name} — ${results[results.length - 1]!.note}` : undefined,
|
||||
});
|
||||
|
||||
const uatStart = Date.now();
|
||||
try {
|
||||
// Sync TERENURI + CLADIRI (quick-count + VALID_FROM delta)
|
||||
const tRes = await syncLayer(username, password, uat.siruta, "TERENURI_ACTIVE", { uatName });
|
||||
const cRes = await syncLayer(username, password, uat.siruta, "CLADIRI_ACTIVE", { uatName });
|
||||
|
||||
let enrichNote = "";
|
||||
if (isMagic) {
|
||||
const client = await EterraClient.create(username, password, { timeoutMs: 120_000 });
|
||||
const eRes = await enrichFeatures(client, uat.siruta);
|
||||
enrichNote = eRes.status === "done"
|
||||
? ` | enrich: ${eRes.enrichedCount}/${eRes.totalFeatures ?? "?"}`
|
||||
: ` | enrich err: ${eRes.error}`;
|
||||
}
|
||||
|
||||
const dur = Math.round((Date.now() - uatStart) / 1000);
|
||||
const parts = [
|
||||
tRes.newFeatures > 0 || (tRes.validFromUpdated ?? 0) > 0
|
||||
? `T:+${tRes.newFeatures}/${tRes.validFromUpdated ?? 0}vf`
|
||||
: "T:ok",
|
||||
cRes.newFeatures > 0 || (cRes.validFromUpdated ?? 0) > 0
|
||||
? `C:+${cRes.newFeatures}/${cRes.validFromUpdated ?? 0}vf`
|
||||
: "C:ok",
|
||||
];
|
||||
const note = `${parts.join(", ")}${enrichNote} (${dur}s)`;
|
||||
results.push({ siruta: uat.siruta, name: uatName, mode, duration: dur, note });
|
||||
console.log(`[refresh-all] ${i + 1}/${uats.length} ${uatName}: ${note}`);
|
||||
} catch (err) {
|
||||
errors++;
|
||||
const dur = Math.round((Date.now() - uatStart) / 1000);
|
||||
const msg = err instanceof Error ? err.message : "Unknown";
|
||||
results.push({ siruta: uat.siruta, name: uatName, mode, duration: dur, note: `ERR: ${msg}` });
|
||||
console.error(`[refresh-all] ${uatName}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
const totalDur = results.reduce((s, r) => s + r.duration, 0);
|
||||
const summary = `${uats.length} UATs, ${errors} erori, ${totalDur}s total`;
|
||||
setProgress({
|
||||
jobId,
|
||||
downloaded: 100,
|
||||
total: 100,
|
||||
status: "done",
|
||||
phase: "Refresh complet finalizat",
|
||||
message: summary,
|
||||
note: results.map((r) => `${r.name}: ${r.note}`).join("\n"),
|
||||
});
|
||||
console.log(`[refresh-all] Done: ${summary}`);
|
||||
setTimeout(() => clearProgress(jobId), 6 * 3_600_000);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Unknown";
|
||||
setProgress({ jobId, downloaded: 0, total: 100, status: "error", phase: "Eroare", message: msg });
|
||||
setTimeout(() => clearProgress(jobId), 3_600_000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Body = {
|
||||
workspaceId: number;
|
||||
orgUnitId: number;
|
||||
year: string;
|
||||
page?: number;
|
||||
nrElements?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/eterra/rgi/applications
|
||||
*
|
||||
* List RGI applications for a given workspace (county) and org unit (UAT).
|
||||
* Proxies eTerra rgi/applicationgrid/list endpoint.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = (await req.json()) as Body;
|
||||
const { workspaceId, orgUnitId, year } = body;
|
||||
|
||||
if (!workspaceId || !orgUnitId || !year) {
|
||||
return NextResponse.json(
|
||||
{ error: "workspaceId, orgUnitId and year are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const username = process.env.ETERRA_USERNAME ?? "";
|
||||
const password = process.env.ETERRA_PASSWORD ?? "";
|
||||
if (!username || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: "Credentials missing" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const client = await EterraClient.create(username, password);
|
||||
|
||||
const page = body.page ?? 0;
|
||||
const nrElements = body.nrElements ?? 25;
|
||||
|
||||
const payload = {
|
||||
filters: [
|
||||
{
|
||||
value: workspaceId,
|
||||
type: "NUMBER",
|
||||
key: "workspace.nomenPk",
|
||||
op: "=",
|
||||
},
|
||||
{
|
||||
value: orgUnitId,
|
||||
type: "NUMBER",
|
||||
key: "partyFunctionByOrgUnitId.nomenPk",
|
||||
op: "=",
|
||||
},
|
||||
],
|
||||
applicationFilters: {
|
||||
applicationType: "own",
|
||||
tabCode: "NUMBER_LIST",
|
||||
year,
|
||||
countyId: workspaceId,
|
||||
adminUnitId: orgUnitId,
|
||||
showAll: false,
|
||||
showNewRequests: false,
|
||||
showSuspended: false,
|
||||
showSolutionDeadlineExpired: false,
|
||||
showPendingLimitation: false,
|
||||
showCadastreNumberAllocated: false,
|
||||
showImmovableRegistered: false,
|
||||
showDocumentIssued: false,
|
||||
showRestitutionClosed: false,
|
||||
showRejected: false,
|
||||
showClosed: false,
|
||||
showWithdrawn: false,
|
||||
showSolutionDeadlineExceeded: false,
|
||||
},
|
||||
sorters: [],
|
||||
nrElements,
|
||||
page,
|
||||
};
|
||||
|
||||
const result = await client.rgiPost(
|
||||
"rgi/applicationgrid/list",
|
||||
payload,
|
||||
);
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Eroare server";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/eterra/rgi/details?applicationId=...
|
||||
*
|
||||
* Fetch RGI application details by application ID.
|
||||
* Proxies eTerra rgi/appdetail/details endpoint.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const applicationId = req.nextUrl.searchParams.get("applicationId");
|
||||
|
||||
if (!applicationId) {
|
||||
return NextResponse.json(
|
||||
{ error: "applicationId is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const username = process.env.ETERRA_USERNAME ?? "";
|
||||
const password = process.env.ETERRA_PASSWORD ?? "";
|
||||
if (!username || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: "Credentials missing" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const client = await EterraClient.create(username, password);
|
||||
|
||||
const result = await client.rgiPost(
|
||||
`rgi/appdetail/details?applicationid=${encodeURIComponent(applicationId)}`,
|
||||
undefined,
|
||||
);
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Eroare server";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Strip Romanian diacritics and replace non-alphanumeric chars with underscores.
|
||||
*/
|
||||
function sanitizeFilename(raw: string): string {
|
||||
return raw
|
||||
.replace(/[ăâ]/g, "a")
|
||||
.replace(/[ĂÂ]/g, "A")
|
||||
.replace(/[îÎ]/g, "i")
|
||||
.replace(/[țȚ]/g, "t")
|
||||
.replace(/[șȘ]/g, "s")
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.replace(/^_|_$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file extension from content-type or server filename.
|
||||
*/
|
||||
function getExtension(contentType: string, serverFilename: string): string {
|
||||
// Try extension from server filename first
|
||||
const dotIdx = serverFilename.lastIndexOf(".");
|
||||
if (dotIdx > 0) {
|
||||
return serverFilename.slice(dotIdx + 1).toLowerCase();
|
||||
}
|
||||
// Fallback to content-type mapping
|
||||
const map: Record<string, string> = {
|
||||
"application/pdf": "pdf",
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"application/zip": "zip",
|
||||
"application/xml": "xml",
|
||||
"text/xml": "xml",
|
||||
};
|
||||
return map[contentType] ?? "pdf";
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/eterra/rgi/download-doc?workspaceId=127&applicationId=X&documentPk=Y&documentTypeId=Z&docType=...&appNo=...&initialAppNo=...
|
||||
*
|
||||
* Downloads an issued document from eTerra RGI.
|
||||
* Tries server-side download first. If that fails (some documents are
|
||||
* restricted to the current actor), returns a JSON blocked response
|
||||
* so the frontend can show a soft message.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const workspaceId = req.nextUrl.searchParams.get("workspaceId");
|
||||
const applicationId = req.nextUrl.searchParams.get("applicationId");
|
||||
const documentPk = req.nextUrl.searchParams.get("documentPk");
|
||||
const documentTypeId = req.nextUrl.searchParams.get("documentTypeId");
|
||||
const docType = req.nextUrl.searchParams.get("docType");
|
||||
const appNo = req.nextUrl.searchParams.get("appNo");
|
||||
const initialAppNo = req.nextUrl.searchParams.get("initialAppNo");
|
||||
|
||||
if (!workspaceId || !applicationId || !documentPk) {
|
||||
return NextResponse.json(
|
||||
{ error: "workspaceId, applicationId and documentPk required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const username = process.env.ETERRA_USERNAME ?? "";
|
||||
const password = process.env.ETERRA_PASSWORD ?? "";
|
||||
if (!username || !password) {
|
||||
return NextResponse.json({ error: "Credentials missing" }, { status: 500 });
|
||||
}
|
||||
|
||||
const client = await EterraClient.create(username, password);
|
||||
|
||||
// Step 0: Set application context (like the web UI does)
|
||||
// This call sets session-level attributes required for document access
|
||||
try {
|
||||
await client.rgiGet(
|
||||
`appDetail/verifyCurrentActorAuthenticated/${applicationId}/${workspaceId}`,
|
||||
);
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
|
||||
// Also load application details (sets more session context)
|
||||
try {
|
||||
await client.rgiPost(
|
||||
`rgi/appdetail/details?applicationid=${applicationId}`,
|
||||
);
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
|
||||
// Try fileVisibility
|
||||
let available = false;
|
||||
if (documentTypeId) {
|
||||
try {
|
||||
const vis = await client.rgiGet(
|
||||
`rgi/appdetail/issueddocs/fileVisibility/${workspaceId}/${applicationId}/${documentTypeId}`,
|
||||
);
|
||||
if (vis && typeof vis === "object" && (vis as Record<string, unknown>).msg === "OK") {
|
||||
available = true;
|
||||
}
|
||||
} catch {
|
||||
// Not available — will try direct download anyway
|
||||
}
|
||||
}
|
||||
|
||||
// Try download (even if fileVisibility failed — context might be enough)
|
||||
try {
|
||||
const { data, contentType, filename: serverFilename } = await client.rgiDownload(
|
||||
`rgi/appdetail/loadDocument/downloadFile/${workspaceId}/${documentPk}`,
|
||||
);
|
||||
if (data.length > 0) {
|
||||
// Build meaningful filename from query params, fallback to server filename
|
||||
const ext = getExtension(contentType, serverFilename);
|
||||
let filename: string;
|
||||
if (docType && appNo) {
|
||||
filename = `${sanitizeFilename(docType)}_${sanitizeFilename(appNo)}.${ext}`;
|
||||
} else if (docType) {
|
||||
filename = `${sanitizeFilename(docType)}.${ext}`;
|
||||
} else if (appNo) {
|
||||
filename = `document_${sanitizeFilename(appNo)}.${ext}`;
|
||||
} else {
|
||||
// Use server filename, but still sanitize it
|
||||
const serverBase = serverFilename.replace(/\.[^.]+$/, "");
|
||||
filename = serverBase && serverBase !== "document"
|
||||
? `${sanitizeFilename(serverBase)}.${ext}`
|
||||
: serverFilename;
|
||||
}
|
||||
|
||||
return new NextResponse(new Uint8Array(data), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Disposition": `attachment; filename="${encodeURIComponent(filename)}"`,
|
||||
"Content-Length": String(data.length),
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Fall through to blocked response
|
||||
}
|
||||
|
||||
// Server-side download not available — return soft blocked response
|
||||
// so the frontend can show a user-friendly message
|
||||
return NextResponse.json(
|
||||
{
|
||||
blocked: true,
|
||||
message: "Documentul nu este inca disponibil pentru descarcare din eTerra.",
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Eroare server";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/eterra/rgi/issued-docs?applicationId=...&workspaceId=...
|
||||
*
|
||||
* List issued documents for an RGI application.
|
||||
* Proxies eTerra rgi/appdetail/issueddocs/list endpoint.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const applicationId = req.nextUrl.searchParams.get("applicationId");
|
||||
const workspaceId = req.nextUrl.searchParams.get("workspaceId");
|
||||
|
||||
if (!applicationId || !workspaceId) {
|
||||
return NextResponse.json(
|
||||
{ error: "applicationId and workspaceId are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const username = process.env.ETERRA_USERNAME ?? "";
|
||||
const password = process.env.ETERRA_PASSWORD ?? "";
|
||||
if (!username || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: "Credentials missing" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const client = await EterraClient.create(username, password);
|
||||
|
||||
const result = await client.rgiPost(
|
||||
`rgi/appdetail/issueddocs/list?applicationid=${encodeURIComponent(applicationId)}&reSaveDocsInPendingAndTiomeOut=false&workspaceid=${encodeURIComponent(workspaceId)}`,
|
||||
{
|
||||
filters: [],
|
||||
sorters: [],
|
||||
nrElements: 50,
|
||||
page: 0,
|
||||
},
|
||||
);
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Eroare server";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* County & geometry refresh — populates GisUat.county + geometry
|
||||
* from eTerra LIMITE_UAT layer.
|
||||
*
|
||||
* Called with an already-authenticated EterraClient (fire-and-forget
|
||||
* after login), so there's no session expiry risk.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Query LIMITE_UAT for all features WITH geometry →
|
||||
* get ADMIN_UNIT_ID, WORKSPACE_ID, AREA_VALUE, LAST_UPDATED_DTM + rings
|
||||
* 2. Map WORKSPACE_ID → county name via verified mapping
|
||||
* 3. Batch-update GisUat: county, workspacePk, geometry, areaValue, lastUpdatedDtm
|
||||
* 4. On subsequent runs: skip UATs where lastUpdatedDtm hasn't changed
|
||||
*/
|
||||
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import type { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||
import { findLayerById } from "@/modules/parcel-sync/services/eterra-layers";
|
||||
|
||||
/**
|
||||
* eTerra WORKSPACE_ID → Romanian county name.
|
||||
*
|
||||
* Verified by cross-referencing LIMITE_UAT sample UATs + DB confirmations.
|
||||
*/
|
||||
const WORKSPACE_TO_COUNTY: Record<number, string> = {
|
||||
10: "Alba",
|
||||
29: "Arad",
|
||||
38: "Argeș",
|
||||
47: "Bacău",
|
||||
56: "Bihor",
|
||||
65: "Bistrița-Năsăud",
|
||||
74: "Botoșani",
|
||||
83: "Brașov",
|
||||
92: "Brăila",
|
||||
109: "Buzău",
|
||||
118: "Caraș-Severin",
|
||||
127: "Cluj",
|
||||
136: "Constanța",
|
||||
145: "Covasna",
|
||||
154: "Dâmbovița",
|
||||
163: "Dolj",
|
||||
172: "Galați",
|
||||
181: "Gorj",
|
||||
190: "Harghita",
|
||||
207: "Hunedoara",
|
||||
216: "Ialomița",
|
||||
225: "Iași",
|
||||
234: "Ilfov",
|
||||
243: "Maramureș",
|
||||
252: "Mehedinți",
|
||||
261: "Mureș",
|
||||
270: "Neamț",
|
||||
289: "Olt",
|
||||
298: "Prahova",
|
||||
305: "Satu Mare",
|
||||
314: "Sălaj",
|
||||
323: "Sibiu",
|
||||
332: "Suceava",
|
||||
341: "Teleorman",
|
||||
350: "Timiș",
|
||||
369: "Tulcea",
|
||||
378: "Vaslui",
|
||||
387: "Vâlcea",
|
||||
396: "Vrancea",
|
||||
403: "București",
|
||||
519: "Călărași",
|
||||
528: "Giurgiu",
|
||||
};
|
||||
|
||||
export async function refreshCountyData(client: EterraClient): Promise<void> {
|
||||
const total = await prisma.gisUat.count();
|
||||
if (total === 0) return;
|
||||
|
||||
// Check how many are missing county OR geometry
|
||||
const [withCounty, withGeometry] = await Promise.all([
|
||||
prisma.gisUat.count({ where: { county: { not: null } } }),
|
||||
prisma.gisUat.count({
|
||||
where: { geometry: { not: Prisma.AnyNull } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const needsCounty = withCounty < total * 0.5;
|
||||
const needsGeometry = withGeometry < total * 0.5;
|
||||
|
||||
if (!needsCounty && !needsGeometry) {
|
||||
console.log(
|
||||
`[county-refresh] ${withCounty}/${total} counties, ${withGeometry}/${total} geometries — skipping.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[county-refresh] Starting: ${withCounty}/${total} counties, ${withGeometry}/${total} geometries.`,
|
||||
);
|
||||
|
||||
// 1. Query LIMITE_UAT — with geometry if needed, without if only county
|
||||
const limiteUat = findLayerById("LIMITE_UAT");
|
||||
if (!limiteUat) {
|
||||
console.error("[county-refresh] LIMITE_UAT layer not configured.");
|
||||
return;
|
||||
}
|
||||
|
||||
const features = await client.fetchAllLayerByWhere(limiteUat, "1=1", {
|
||||
outFields: "ADMIN_UNIT_ID,WORKSPACE_ID,AREA_VALUE,LAST_UPDATED_DTM",
|
||||
returnGeometry: needsGeometry,
|
||||
pageSize: 1000,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[county-refresh] LIMITE_UAT: ${features.length} features` +
|
||||
`${needsGeometry ? " (with geometry)" : ""}.`,
|
||||
);
|
||||
if (features.length === 0) return;
|
||||
|
||||
// 2. Log unknown workspaces
|
||||
const seenWs = new Set<number>();
|
||||
for (const f of features) {
|
||||
const ws = Number(f.attributes?.WORKSPACE_ID ?? 0);
|
||||
if (ws > 0 && !(ws in WORKSPACE_TO_COUNTY) && !seenWs.has(ws)) {
|
||||
seenWs.add(ws);
|
||||
const siruta = String(f.attributes?.ADMIN_UNIT_ID ?? "").replace(/\.0$/, "");
|
||||
console.warn(
|
||||
`[county-refresh] Unknown workspace ${ws} (SIRUTA ${siruta}). Add to mapping.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Upsert each UAT with county, workspacePk, geometry, area, lastUpdatedDtm
|
||||
let updated = 0;
|
||||
const BATCH = 50;
|
||||
|
||||
for (let i = 0; i < features.length; i += BATCH) {
|
||||
const batch = features.slice(i, i + BATCH);
|
||||
const ops = [];
|
||||
|
||||
for (const f of batch) {
|
||||
const siruta = String(f.attributes?.ADMIN_UNIT_ID ?? "")
|
||||
.trim()
|
||||
.replace(/\.0$/, "");
|
||||
const ws = Number(f.attributes?.WORKSPACE_ID ?? 0);
|
||||
if (!siruta || ws <= 0) continue;
|
||||
|
||||
const county = WORKSPACE_TO_COUNTY[ws];
|
||||
const areaValue = Number(f.attributes?.AREA_VALUE ?? 0) || null;
|
||||
const lastUpdatedDtm = f.attributes?.LAST_UPDATED_DTM != null
|
||||
? String(f.attributes.LAST_UPDATED_DTM)
|
||||
: null;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const geom = (f as any).geometry ?? null;
|
||||
|
||||
const data: Prisma.GisUatUpdateInput = {
|
||||
workspacePk: ws,
|
||||
...(county ? { county } : {}),
|
||||
...(areaValue ? { areaValue } : {}),
|
||||
...(lastUpdatedDtm ? { lastUpdatedDtm } : {}),
|
||||
...(geom ? { geometry: geom as Prisma.InputJsonValue } : {}),
|
||||
};
|
||||
|
||||
ops.push(
|
||||
prisma.gisUat.updateMany({
|
||||
where: { siruta },
|
||||
data,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (ops.length > 0) {
|
||||
const results = await prisma.$transaction(ops);
|
||||
for (const r of results) updated += r.count;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[county-refresh] Done: ${updated}/${total} updated.`);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getSessionStatus,
|
||||
} from "@/modules/parcel-sync/services/session-store";
|
||||
import { getEterraHealth } from "@/modules/parcel-sync/services/eterra-health";
|
||||
import { refreshCountyData } from "./county-refresh";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -104,9 +105,14 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
// Attempt login
|
||||
await EterraClient.create(username, password);
|
||||
const client = await EterraClient.create(username, password);
|
||||
createSession(username, password);
|
||||
|
||||
// Fire-and-forget: populate county data using fresh client
|
||||
refreshCountyData(client).catch((err) =>
|
||||
console.error("[session] County refresh failed:", err),
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Eroare server";
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* GET /api/eterra/stats
|
||||
*
|
||||
* Lightweight endpoint for the monitor page — returns aggregate counts
|
||||
* suitable for polling every 30s without heavy DB load.
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* totalUats: number,
|
||||
* totalFeatures: number,
|
||||
* totalTerenuri: number,
|
||||
* totalCladiri: number,
|
||||
* totalEnriched: number,
|
||||
* totalNoGeom: number,
|
||||
* countiesWithData: number,
|
||||
* lastSyncAt: string | null,
|
||||
* dbSizeMb: number | null,
|
||||
* }
|
||||
*/
|
||||
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const [
|
||||
totalUats,
|
||||
totalFeatures,
|
||||
totalTerenuri,
|
||||
totalCladiri,
|
||||
totalEnriched,
|
||||
totalNoGeom,
|
||||
countyAgg,
|
||||
lastSync,
|
||||
dbSize,
|
||||
] = await Promise.all([
|
||||
prisma.gisUat.count(),
|
||||
prisma.gisFeature.count({ where: { objectId: { gt: 0 } } }),
|
||||
prisma.gisFeature.count({ where: { layerId: "TERENURI_ACTIVE", objectId: { gt: 0 } } }),
|
||||
prisma.gisFeature.count({ where: { layerId: "CLADIRI_ACTIVE", objectId: { gt: 0 } } }),
|
||||
prisma.gisFeature.count({ where: { enrichedAt: { not: null } } }),
|
||||
prisma.gisFeature.count({ where: { geometrySource: "NO_GEOMETRY" } }),
|
||||
prisma.gisUat.groupBy({
|
||||
by: ["county"],
|
||||
where: { county: { not: null } },
|
||||
_count: true,
|
||||
}),
|
||||
prisma.gisSyncRun.findFirst({
|
||||
where: { status: "done" },
|
||||
orderBy: { completedAt: "desc" },
|
||||
select: { completedAt: true },
|
||||
}),
|
||||
prisma.$queryRaw<Array<{ size: string }>>`
|
||||
SELECT pg_size_pretty(pg_database_size(current_database())) as size
|
||||
`,
|
||||
]);
|
||||
|
||||
// Parse DB size to MB
|
||||
const sizeStr = dbSize[0]?.size ?? "";
|
||||
let dbSizeMb: number | null = null;
|
||||
const mbMatch = sizeStr.match(/([\d.]+)\s*(MB|GB|TB)/i);
|
||||
if (mbMatch) {
|
||||
const val = parseFloat(mbMatch[1]!);
|
||||
const unit = mbMatch[2]!.toUpperCase();
|
||||
dbSizeMb = unit === "GB" ? val * 1024 : unit === "TB" ? val * 1024 * 1024 : val;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
totalUats,
|
||||
totalFeatures,
|
||||
totalTerenuri,
|
||||
totalCladiri,
|
||||
totalEnriched,
|
||||
totalNoGeom,
|
||||
countiesWithData: countyAgg.length,
|
||||
lastSyncAt: lastSync?.completedAt?.toISOString() ?? null,
|
||||
dbSizeMb: dbSizeMb ? Math.round(dbSizeMb) : null,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Eroare server";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* POST /api/eterra/sync-all-counties
|
||||
*
|
||||
* Starts a background sync for ALL counties in the database (entire Romania).
|
||||
* Iterates counties sequentially, running county-sync logic for each.
|
||||
* Returns immediately with jobId — progress via /api/eterra/progress.
|
||||
*
|
||||
* Body: {} (no params needed)
|
||||
*/
|
||||
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import {
|
||||
setProgress,
|
||||
clearProgress,
|
||||
type SyncProgress,
|
||||
} from "@/modules/parcel-sync/services/progress-store";
|
||||
import { syncLayer } from "@/modules/parcel-sync/services/sync-service";
|
||||
import { enrichFeatures } from "@/modules/parcel-sync/services/enrich-service";
|
||||
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||
import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-health";
|
||||
import { createAppNotification } from "@/core/notifications/app-notifications";
|
||||
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
|
||||
import { firePmtilesRebuild } from "@/modules/parcel-sync/services/pmtiles-webhook";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Concurrency guard — blocks both this and single county sync */
|
||||
const g = globalThis as {
|
||||
__countySyncRunning?: string;
|
||||
__allCountiesSyncRunning?: boolean;
|
||||
};
|
||||
|
||||
export async function POST() {
|
||||
const session = getSessionCredentials();
|
||||
const username = String(session?.username || process.env.ETERRA_USERNAME || "").trim();
|
||||
const password = String(session?.password || process.env.ETERRA_PASSWORD || "").trim();
|
||||
if (!username || !password) {
|
||||
return Response.json(
|
||||
{ error: "Credentiale lipsa — conecteaza-te la eTerra mai intai." },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
if (g.__allCountiesSyncRunning) {
|
||||
return Response.json(
|
||||
{ error: "Sync All Romania deja in curs" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
if (g.__countySyncRunning) {
|
||||
return Response.json(
|
||||
{ error: `Sync judet deja in curs: ${g.__countySyncRunning}` },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const jobId = crypto.randomUUID();
|
||||
g.__allCountiesSyncRunning = true;
|
||||
|
||||
setProgress({
|
||||
jobId,
|
||||
downloaded: 0,
|
||||
total: 100,
|
||||
status: "running",
|
||||
phase: "Pregatire sync Romania...",
|
||||
});
|
||||
|
||||
void runAllCountiesSync(jobId, username, password);
|
||||
|
||||
return Response.json(
|
||||
{ jobId, message: "Sync All Romania pornit" },
|
||||
{ status: 202 },
|
||||
);
|
||||
}
|
||||
|
||||
async function runAllCountiesSync(
|
||||
jobId: string,
|
||||
username: string,
|
||||
password: string,
|
||||
) {
|
||||
const push = (p: Partial<SyncProgress>) =>
|
||||
setProgress({
|
||||
jobId,
|
||||
downloaded: 0,
|
||||
total: 100,
|
||||
status: "running",
|
||||
...p,
|
||||
} as SyncProgress);
|
||||
|
||||
try {
|
||||
// Health check
|
||||
const health = await checkEterraHealthNow();
|
||||
if (!health.available) {
|
||||
setProgress({
|
||||
jobId,
|
||||
downloaded: 0,
|
||||
total: 100,
|
||||
status: "error",
|
||||
phase: "eTerra indisponibil",
|
||||
message: health.message ?? "maintenance",
|
||||
});
|
||||
g.__allCountiesSyncRunning = false;
|
||||
setTimeout(() => clearProgress(jobId), 3_600_000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all distinct counties, ordered alphabetically
|
||||
const countyRows = await prisma.gisUat.groupBy({
|
||||
by: ["county"],
|
||||
where: { county: { not: null } },
|
||||
_count: true,
|
||||
orderBy: { county: "asc" },
|
||||
});
|
||||
|
||||
const counties = countyRows
|
||||
.map((r) => r.county)
|
||||
.filter((c): c is string => c != null);
|
||||
|
||||
if (counties.length === 0) {
|
||||
setProgress({
|
||||
jobId,
|
||||
downloaded: 100,
|
||||
total: 100,
|
||||
status: "done",
|
||||
phase: "Niciun judet gasit in DB",
|
||||
});
|
||||
g.__allCountiesSyncRunning = false;
|
||||
setTimeout(() => clearProgress(jobId), 3_600_000);
|
||||
return;
|
||||
}
|
||||
|
||||
push({ phase: `0/${counties.length} judete — pornire...` });
|
||||
|
||||
const countyResults: Array<{
|
||||
county: string;
|
||||
uatCount: number;
|
||||
errors: number;
|
||||
duration: number;
|
||||
}> = [];
|
||||
let totalErrors = 0;
|
||||
let totalUats = 0;
|
||||
|
||||
for (let ci = 0; ci < counties.length; ci++) {
|
||||
const county = counties[ci]!;
|
||||
g.__countySyncRunning = county;
|
||||
|
||||
// Get UATs for this county
|
||||
const uats = await prisma.$queryRawUnsafe<
|
||||
Array<{
|
||||
siruta: string;
|
||||
name: string | null;
|
||||
total: number;
|
||||
enriched: number;
|
||||
}>
|
||||
>(
|
||||
`SELECT u.siruta, u.name,
|
||||
COALESCE(f.total, 0)::int as total,
|
||||
COALESCE(f.enriched, 0)::int as enriched
|
||||
FROM "GisUat" u
|
||||
LEFT JOIN (
|
||||
SELECT siruta, COUNT(*)::int as total,
|
||||
COUNT(*) FILTER (WHERE "enrichedAt" IS NOT NULL)::int as enriched
|
||||
FROM "GisFeature"
|
||||
WHERE "layerId" IN ('TERENURI_ACTIVE','CLADIRI_ACTIVE') AND "objectId" > 0
|
||||
GROUP BY siruta
|
||||
) f ON u.siruta = f.siruta
|
||||
WHERE u.county = $1
|
||||
ORDER BY COALESCE(f.total, 0) DESC`,
|
||||
county,
|
||||
);
|
||||
|
||||
if (uats.length === 0) {
|
||||
countyResults.push({ county, uatCount: 0, errors: 0, duration: 0 });
|
||||
continue;
|
||||
}
|
||||
|
||||
const countyStart = Date.now();
|
||||
let countyErrors = 0;
|
||||
|
||||
for (let i = 0; i < uats.length; i++) {
|
||||
const uat = uats[i]!;
|
||||
const uatName = uat.name ?? uat.siruta;
|
||||
const ratio = uat.total > 0 ? uat.enriched / uat.total : 0;
|
||||
const isMagic = ratio > 0.3;
|
||||
const mode = isMagic ? "magic" : "base";
|
||||
|
||||
// Progress: county level + UAT level — update before starting UAT
|
||||
const countyPct = ci / counties.length;
|
||||
const uatPct = i / uats.length;
|
||||
const overallPct = Math.round((countyPct + uatPct / counties.length) * 100);
|
||||
|
||||
push({
|
||||
downloaded: overallPct,
|
||||
total: 100,
|
||||
phase: `[${ci + 1}/${counties.length}] ${county} — [${i + 1}/${uats.length}] ${uatName} (${mode})`,
|
||||
note: countyResults.length > 0
|
||||
? `Ultimul judet: ${countyResults[countyResults.length - 1]!.county} (${countyResults[countyResults.length - 1]!.uatCount} UAT, ${countyResults[countyResults.length - 1]!.errors} err)`
|
||||
: undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
await syncLayer(username, password, uat.siruta, "TERENURI_ACTIVE", { uatName, jobId, isSubStep: true });
|
||||
await syncLayer(username, password, uat.siruta, "CLADIRI_ACTIVE", { uatName, jobId, isSubStep: true });
|
||||
|
||||
// LIMITE_INTRAV_DYNAMIC — best effort
|
||||
try {
|
||||
await syncLayer(username, password, uat.siruta, "LIMITE_INTRAV_DYNAMIC", { uatName, jobId, isSubStep: true });
|
||||
} catch { /* skip */ }
|
||||
|
||||
// Enrichment for magic mode
|
||||
if (isMagic) {
|
||||
try {
|
||||
const client = await EterraClient.create(username, password, { timeoutMs: 120_000 });
|
||||
await enrichFeatures(client, uat.siruta);
|
||||
} catch {
|
||||
// Enrichment failure is non-fatal
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
countyErrors++;
|
||||
const msg = err instanceof Error ? err.message : "Unknown";
|
||||
console.error(`[sync-all] ${county}/${uatName}: ${msg}`);
|
||||
}
|
||||
|
||||
// Update progress AFTER UAT completion
|
||||
const completedUatPct = (i + 1) / uats.length;
|
||||
const completedOverallPct = Math.round((countyPct + completedUatPct / counties.length) * 100);
|
||||
push({
|
||||
downloaded: completedOverallPct,
|
||||
total: 100,
|
||||
phase: `[${ci + 1}/${counties.length}] ${county} — [${i + 1}/${uats.length}] ${uatName} finalizat`,
|
||||
});
|
||||
}
|
||||
|
||||
const dur = Math.round((Date.now() - countyStart) / 1000);
|
||||
countyResults.push({ county, uatCount: uats.length, errors: countyErrors, duration: dur });
|
||||
totalErrors += countyErrors;
|
||||
totalUats += uats.length;
|
||||
|
||||
console.log(
|
||||
`[sync-all] ${ci + 1}/${counties.length} ${county}: ${uats.length} UAT, ${countyErrors} err, ${dur}s`,
|
||||
);
|
||||
}
|
||||
|
||||
const totalDur = countyResults.reduce((s, r) => s + r.duration, 0);
|
||||
const summary = `${counties.length} judete, ${totalUats} UAT-uri, ${totalErrors} erori, ${formatDuration(totalDur)}`;
|
||||
|
||||
setProgress({
|
||||
jobId,
|
||||
downloaded: 100,
|
||||
total: 100,
|
||||
status: totalErrors > 0 && totalErrors === totalUats ? "error" : "done",
|
||||
phase: "Sync Romania finalizat",
|
||||
message: summary,
|
||||
});
|
||||
|
||||
await createAppNotification({
|
||||
type: totalErrors > 0 ? "sync-error" : "sync-complete",
|
||||
title: totalErrors > 0
|
||||
? `Sync Romania: ${totalErrors} erori din ${totalUats} UAT-uri`
|
||||
: `Sync Romania: ${totalUats} UAT-uri in ${counties.length} judete`,
|
||||
message: summary,
|
||||
metadata: { jobId, counties: counties.length, totalUats, totalErrors, totalDuration: totalDur },
|
||||
});
|
||||
|
||||
console.log(`[sync-all] Done: ${summary}`);
|
||||
|
||||
// Trigger PMTiles rebuild after full Romania sync
|
||||
await firePmtilesRebuild("all-counties-sync-complete", {
|
||||
counties: counties.length,
|
||||
totalUats,
|
||||
totalErrors,
|
||||
});
|
||||
|
||||
setTimeout(() => clearProgress(jobId), 12 * 3_600_000);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Unknown";
|
||||
setProgress({
|
||||
jobId,
|
||||
downloaded: 0,
|
||||
total: 100,
|
||||
status: "error",
|
||||
phase: "Eroare",
|
||||
message: msg,
|
||||
});
|
||||
await createAppNotification({
|
||||
type: "sync-error",
|
||||
title: "Sync Romania: eroare generala",
|
||||
message: msg,
|
||||
metadata: { jobId },
|
||||
});
|
||||
setTimeout(() => clearProgress(jobId), 3_600_000);
|
||||
} finally {
|
||||
g.__allCountiesSyncRunning = false;
|
||||
g.__countySyncRunning = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, "0")}s`;
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
return `${h}h${String(m).padStart(2, "0")}m`;
|
||||
}
|
||||
@@ -187,65 +187,106 @@ async function runBackground(params: {
|
||||
getLayerFreshness(siruta, "CLADIRI_ACTIVE"),
|
||||
]);
|
||||
|
||||
const terenuriNeedsSync =
|
||||
forceSync ||
|
||||
!isFresh(terenuriStatus.lastSynced) ||
|
||||
terenuriStatus.featureCount === 0;
|
||||
const cladiriNeedsSync =
|
||||
forceSync ||
|
||||
!isFresh(cladiriStatus.lastSynced) ||
|
||||
cladiriStatus.featureCount === 0;
|
||||
const terenuriNeedsFullSync =
|
||||
forceSync || terenuriStatus.featureCount === 0;
|
||||
const cladiriNeedsFullSync =
|
||||
forceSync || cladiriStatus.featureCount === 0;
|
||||
|
||||
if (terenuriNeedsSync) {
|
||||
phase = "Sincronizare terenuri";
|
||||
push({});
|
||||
const r = await syncLayer(username, password, siruta, "TERENURI_ACTIVE", {
|
||||
forceFullSync: forceSync,
|
||||
jobId,
|
||||
isSubStep: true,
|
||||
});
|
||||
if (r.status === "error")
|
||||
throw new Error(r.error ?? "Sync terenuri failed");
|
||||
}
|
||||
// Always call syncLayer — it handles quick-count + VALID_FROM delta internally.
|
||||
// Only force full download when no local data or explicit forceSync.
|
||||
phase = "Sincronizare terenuri";
|
||||
push({});
|
||||
const terenuriResult = await syncLayer(username, password, siruta, "TERENURI_ACTIVE", {
|
||||
forceFullSync: terenuriNeedsFullSync,
|
||||
jobId,
|
||||
isSubStep: true,
|
||||
});
|
||||
if (terenuriResult.status === "error")
|
||||
throw new Error(terenuriResult.error ?? "Sync terenuri failed");
|
||||
updateOverall(0.5);
|
||||
|
||||
if (cladiriNeedsSync) {
|
||||
phase = "Sincronizare clădiri";
|
||||
phase = "Sincronizare clădiri";
|
||||
push({});
|
||||
const cladiriResult = await syncLayer(username, password, siruta, "CLADIRI_ACTIVE", {
|
||||
forceFullSync: cladiriNeedsFullSync,
|
||||
jobId,
|
||||
isSubStep: true,
|
||||
});
|
||||
if (cladiriResult.status === "error")
|
||||
throw new Error(cladiriResult.error ?? "Sync clădiri failed");
|
||||
|
||||
// Sync admin layers — skip if synced within 24h
|
||||
for (const adminLayer of ["LIMITE_INTRAV_DYNAMIC", "LIMITE_UAT"]) {
|
||||
const adminStatus = await getLayerFreshness(siruta, adminLayer);
|
||||
if (!forceSync && isFresh(adminStatus.lastSynced, 24)) continue;
|
||||
phase = `Sincronizare ${adminLayer === "LIMITE_UAT" ? "limite UAT" : "limite intravilan"}`;
|
||||
push({});
|
||||
const r = await syncLayer(username, password, siruta, "CLADIRI_ACTIVE", {
|
||||
forceFullSync: forceSync,
|
||||
jobId,
|
||||
isSubStep: true,
|
||||
});
|
||||
if (r.status === "error")
|
||||
throw new Error(r.error ?? "Sync clădiri failed");
|
||||
try {
|
||||
await syncLayer(username, password, siruta, adminLayer, {
|
||||
forceFullSync: forceSync,
|
||||
jobId,
|
||||
isSubStep: true,
|
||||
});
|
||||
} catch {
|
||||
note = `Avertisment: ${adminLayer} nu s-a sincronizat`;
|
||||
push({});
|
||||
}
|
||||
}
|
||||
|
||||
if (!terenuriNeedsSync && !cladiriNeedsSync) {
|
||||
note = "Date proaspete — sync skip";
|
||||
}
|
||||
const syncSummary = [
|
||||
terenuriResult.newFeatures > 0 ? `${terenuriResult.newFeatures} terenuri noi` : null,
|
||||
terenuriResult.validFromUpdated ? `${terenuriResult.validFromUpdated} terenuri actualizate` : null,
|
||||
cladiriResult.newFeatures > 0 ? `${cladiriResult.newFeatures} cladiri noi` : null,
|
||||
cladiriResult.validFromUpdated ? `${cladiriResult.validFromUpdated} cladiri actualizate` : null,
|
||||
].filter(Boolean);
|
||||
note = syncSummary.length > 0 ? syncSummary.join(", ") : "Fără schimbări";
|
||||
finishPhase();
|
||||
|
||||
/* ── Phase 2: No-geometry import (optional) ──────── */
|
||||
if (hasNoGeom && weights.noGeom > 0) {
|
||||
setPhase("Import parcele fără geometrie", weights.noGeom);
|
||||
const noGeomClient = await EterraClient.create(username, password, {
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
const res = await syncNoGeometryParcels(noGeomClient, siruta, {
|
||||
onProgress: (done, tot, ph) => {
|
||||
phase = ph;
|
||||
push({});
|
||||
},
|
||||
});
|
||||
if (res.status === "error") {
|
||||
note = `Avertisment no-geom: ${res.error}`;
|
||||
setPhase("Verificare parcele fără geometrie", weights.noGeom);
|
||||
// Skip no-geom import if recently done (within 48h) and not forced
|
||||
const { PrismaClient } = await import("@prisma/client");
|
||||
const _prisma = new PrismaClient();
|
||||
let skipNoGeom = false;
|
||||
try {
|
||||
const recentNoGeom = await _prisma.gisFeature.findFirst({
|
||||
where: {
|
||||
layerId: "TERENURI_ACTIVE",
|
||||
siruta,
|
||||
geometrySource: "NO_GEOMETRY",
|
||||
updatedAt: { gte: new Date(Date.now() - 48 * 60 * 60 * 1000) },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
skipNoGeom = !forceSync && recentNoGeom != null;
|
||||
} catch { /* proceed with import */ }
|
||||
await _prisma.$disconnect();
|
||||
|
||||
if (skipNoGeom) {
|
||||
note = "Parcele fără geometrie — actualizate recent, skip";
|
||||
push({});
|
||||
} else {
|
||||
const cleanNote =
|
||||
res.cleaned > 0 ? `, ${res.cleaned} vechi șterse` : "";
|
||||
note = `${res.imported} parcele noi importate${cleanNote}`;
|
||||
phase = "Import parcele fără geometrie";
|
||||
push({});
|
||||
const noGeomClient = await EterraClient.create(username, password, {
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
const res = await syncNoGeometryParcels(noGeomClient, siruta, {
|
||||
onProgress: (done, tot, ph) => {
|
||||
phase = ph;
|
||||
push({});
|
||||
},
|
||||
});
|
||||
if (res.status === "error") {
|
||||
note = `Avertisment no-geom: ${res.error}`;
|
||||
push({});
|
||||
} else {
|
||||
const cleanNote =
|
||||
res.cleaned > 0 ? `, ${res.cleaned} vechi șterse` : "";
|
||||
note = `${res.imported} parcele noi importate${cleanNote}`;
|
||||
push({});
|
||||
}
|
||||
}
|
||||
finishPhase();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* POST /api/eterra/sync-county
|
||||
*
|
||||
* Starts a background sync for all UATs in a given county.
|
||||
* Syncs TERENURI_ACTIVE, CLADIRI_ACTIVE, and LIMITE_INTRAV_DYNAMIC.
|
||||
* UATs with >30% enrichment → magic mode (sync + enrichment).
|
||||
*
|
||||
* Body: { county: string }
|
||||
* Returns immediately with jobId — progress via /api/eterra/progress.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import {
|
||||
setProgress,
|
||||
clearProgress,
|
||||
type SyncProgress,
|
||||
} from "@/modules/parcel-sync/services/progress-store";
|
||||
import { syncLayer } from "@/modules/parcel-sync/services/sync-service";
|
||||
import { enrichFeatures } from "@/modules/parcel-sync/services/enrich-service";
|
||||
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||
import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-health";
|
||||
import { createAppNotification } from "@/core/notifications/app-notifications";
|
||||
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
|
||||
import { firePmtilesRebuild } from "@/modules/parcel-sync/services/pmtiles-webhook";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Concurrency guard */
|
||||
const g = globalThis as { __countySyncRunning?: string; __allCountiesSyncRunning?: boolean };
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let body: { county?: string };
|
||||
try {
|
||||
body = (await req.json()) as { county?: string };
|
||||
} catch {
|
||||
return Response.json({ error: "Body invalid" }, { status: 400 });
|
||||
}
|
||||
|
||||
const session = getSessionCredentials();
|
||||
const username = String(session?.username || process.env.ETERRA_USERNAME || "").trim();
|
||||
const password = String(session?.password || process.env.ETERRA_PASSWORD || "").trim();
|
||||
if (!username || !password) {
|
||||
return Response.json(
|
||||
{ error: "Credentiale lipsa — conecteaza-te la eTerra mai intai." },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const county = body.county?.trim();
|
||||
if (!county) {
|
||||
return Response.json({ error: "Judetul lipseste" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (g.__allCountiesSyncRunning) {
|
||||
return Response.json(
|
||||
{ error: "Sync All Romania in curs — asteapta sa se termine" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
if (g.__countySyncRunning) {
|
||||
return Response.json(
|
||||
{ error: `Sync judet deja in curs: ${g.__countySyncRunning}` },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const jobId = crypto.randomUUID();
|
||||
g.__countySyncRunning = county;
|
||||
|
||||
setProgress({
|
||||
jobId,
|
||||
downloaded: 0,
|
||||
total: 100,
|
||||
status: "running",
|
||||
phase: `Pregatire sync ${county}`,
|
||||
});
|
||||
|
||||
void runCountySync(jobId, county, username, password);
|
||||
|
||||
return Response.json(
|
||||
{ jobId, message: `Sync judet ${county} pornit` },
|
||||
{ status: 202 },
|
||||
);
|
||||
}
|
||||
|
||||
async function runCountySync(
|
||||
jobId: string,
|
||||
county: string,
|
||||
username: string,
|
||||
password: string,
|
||||
) {
|
||||
const push = (p: Partial<SyncProgress>) =>
|
||||
setProgress({
|
||||
jobId,
|
||||
downloaded: 0,
|
||||
total: 100,
|
||||
status: "running",
|
||||
...p,
|
||||
} as SyncProgress);
|
||||
|
||||
try {
|
||||
// Health check
|
||||
const health = await checkEterraHealthNow();
|
||||
if (!health.available) {
|
||||
setProgress({
|
||||
jobId,
|
||||
downloaded: 0,
|
||||
total: 100,
|
||||
status: "error",
|
||||
phase: "eTerra indisponibil",
|
||||
message: health.message ?? "maintenance",
|
||||
});
|
||||
await createAppNotification({
|
||||
type: "sync-error",
|
||||
title: `Sync ${county}: eTerra indisponibil`,
|
||||
message: health.message ?? "Serviciul eTerra este in mentenanta",
|
||||
metadata: { county, jobId },
|
||||
});
|
||||
g.__countySyncRunning = undefined;
|
||||
setTimeout(() => clearProgress(jobId), 3_600_000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find all UATs in this county with feature stats
|
||||
const uats = await prisma.$queryRawUnsafe<
|
||||
Array<{
|
||||
siruta: string;
|
||||
name: string | null;
|
||||
total: number;
|
||||
enriched: number;
|
||||
}>
|
||||
>(
|
||||
`SELECT u.siruta, u.name,
|
||||
COALESCE(f.total, 0)::int as total,
|
||||
COALESCE(f.enriched, 0)::int as enriched
|
||||
FROM "GisUat" u
|
||||
LEFT JOIN (
|
||||
SELECT siruta, COUNT(*)::int as total,
|
||||
COUNT(*) FILTER (WHERE "enrichedAt" IS NOT NULL)::int as enriched
|
||||
FROM "GisFeature"
|
||||
WHERE "layerId" IN ('TERENURI_ACTIVE','CLADIRI_ACTIVE') AND "objectId" > 0
|
||||
GROUP BY siruta
|
||||
) f ON u.siruta = f.siruta
|
||||
WHERE u.county = $1
|
||||
ORDER BY COALESCE(f.total, 0) DESC`,
|
||||
county,
|
||||
);
|
||||
|
||||
if (uats.length === 0) {
|
||||
setProgress({
|
||||
jobId,
|
||||
downloaded: 100,
|
||||
total: 100,
|
||||
status: "done",
|
||||
phase: `Niciun UAT gasit in ${county}`,
|
||||
});
|
||||
g.__countySyncRunning = undefined;
|
||||
setTimeout(() => clearProgress(jobId), 3_600_000);
|
||||
return;
|
||||
}
|
||||
|
||||
const results: Array<{
|
||||
siruta: string;
|
||||
name: string;
|
||||
mode: string;
|
||||
duration: number;
|
||||
note: string;
|
||||
}> = [];
|
||||
let errors = 0;
|
||||
|
||||
let totalNewFeatures = 0;
|
||||
|
||||
for (let i = 0; i < uats.length; i++) {
|
||||
const uat = uats[i]!;
|
||||
const uatName = uat.name ?? uat.siruta;
|
||||
const ratio = uat.total > 0 ? uat.enriched / uat.total : 0;
|
||||
const isMagic = ratio > 0.3;
|
||||
const mode = isMagic ? "magic" : "base";
|
||||
const pct = Math.round((i / uats.length) * 100);
|
||||
|
||||
push({
|
||||
downloaded: pct,
|
||||
total: 100,
|
||||
phase: `[${i + 1}/${uats.length}] ${uatName} (${mode})`,
|
||||
note:
|
||||
results.length > 0
|
||||
? `Ultimul: ${results[results.length - 1]!.name} — ${results[results.length - 1]!.note}`
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const uatStart = Date.now();
|
||||
try {
|
||||
// Sync TERENURI + CLADIRI — pass jobId for sub-progress
|
||||
const tRes = await syncLayer(username, password, uat.siruta, "TERENURI_ACTIVE", {
|
||||
uatName, jobId, isSubStep: true,
|
||||
});
|
||||
const cRes = await syncLayer(username, password, uat.siruta, "CLADIRI_ACTIVE", {
|
||||
uatName, jobId, isSubStep: true,
|
||||
});
|
||||
|
||||
// Sync ADMINISTRATIV (intravilan) — wrapped in try/catch since it needs UAT geometry
|
||||
let adminNote = "";
|
||||
try {
|
||||
const aRes = await syncLayer(
|
||||
username,
|
||||
password,
|
||||
uat.siruta,
|
||||
"LIMITE_INTRAV_DYNAMIC",
|
||||
{ uatName, jobId, isSubStep: true },
|
||||
);
|
||||
if (aRes.newFeatures > 0) {
|
||||
adminNote = ` | A:+${aRes.newFeatures}`;
|
||||
}
|
||||
} catch {
|
||||
adminNote = " | A:skip";
|
||||
}
|
||||
|
||||
// Enrichment for magic mode
|
||||
let enrichNote = "";
|
||||
if (isMagic) {
|
||||
const client = await EterraClient.create(username, password, {
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
const eRes = await enrichFeatures(client, uat.siruta);
|
||||
enrichNote =
|
||||
eRes.status === "done"
|
||||
? ` | enrich: ${eRes.enrichedCount}/${eRes.totalFeatures ?? "?"}`
|
||||
: ` | enrich err: ${eRes.error}`;
|
||||
}
|
||||
|
||||
const dur = Math.round((Date.now() - uatStart) / 1000);
|
||||
const parts = [
|
||||
tRes.newFeatures > 0 || (tRes.validFromUpdated ?? 0) > 0
|
||||
? `T:+${tRes.newFeatures}/${tRes.validFromUpdated ?? 0}vf`
|
||||
: "T:ok",
|
||||
cRes.newFeatures > 0 || (cRes.validFromUpdated ?? 0) > 0
|
||||
? `C:+${cRes.newFeatures}/${cRes.validFromUpdated ?? 0}vf`
|
||||
: "C:ok",
|
||||
];
|
||||
totalNewFeatures += tRes.newFeatures + cRes.newFeatures;
|
||||
const note = `${parts.join(", ")}${adminNote}${enrichNote} (${dur}s)`;
|
||||
results.push({ siruta: uat.siruta, name: uatName, mode, duration: dur, note });
|
||||
|
||||
// Update progress AFTER UAT completion (so % reflects completed work)
|
||||
const completedPct = Math.round(((i + 1) / uats.length) * 100);
|
||||
push({
|
||||
downloaded: completedPct,
|
||||
total: 100,
|
||||
phase: `[${i + 1}/${uats.length}] ${uatName} finalizat`,
|
||||
note: `${note}`,
|
||||
});
|
||||
|
||||
console.log(`[sync-county:${county}] ${i + 1}/${uats.length} ${uatName}: ${note}`);
|
||||
} catch (err) {
|
||||
errors++;
|
||||
const dur = Math.round((Date.now() - uatStart) / 1000);
|
||||
const msg = err instanceof Error ? err.message : "Unknown";
|
||||
results.push({
|
||||
siruta: uat.siruta,
|
||||
name: uatName,
|
||||
mode,
|
||||
duration: dur,
|
||||
note: `ERR: ${msg}`,
|
||||
});
|
||||
// Still update progress after error
|
||||
const completedPct = Math.round(((i + 1) / uats.length) * 100);
|
||||
push({
|
||||
downloaded: completedPct,
|
||||
total: 100,
|
||||
phase: `[${i + 1}/${uats.length}] ${uatName} — eroare`,
|
||||
});
|
||||
console.error(`[sync-county:${county}] ${uatName}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
const totalDur = results.reduce((s, r) => s + r.duration, 0);
|
||||
const summary = `${uats.length} UAT-uri, ${errors} erori, ${totalDur}s total`;
|
||||
|
||||
setProgress({
|
||||
jobId,
|
||||
downloaded: 100,
|
||||
total: 100,
|
||||
status: errors > 0 && errors === uats.length ? "error" : "done",
|
||||
phase: `Sync ${county} finalizat`,
|
||||
message: summary,
|
||||
note: results.map((r) => `${r.name}: ${r.note}`).join("\n"),
|
||||
});
|
||||
|
||||
await createAppNotification({
|
||||
type: errors > 0 ? "sync-error" : "sync-complete",
|
||||
title:
|
||||
errors > 0
|
||||
? `Sync ${county}: ${errors} erori din ${uats.length} UAT-uri`
|
||||
: `Sync ${county}: ${uats.length} UAT-uri sincronizate`,
|
||||
message: summary,
|
||||
metadata: { county, jobId, uatCount: uats.length, errors, totalDuration: totalDur },
|
||||
});
|
||||
|
||||
console.log(`[sync-county:${county}] Done: ${summary}`);
|
||||
|
||||
// Trigger PMTiles rebuild if new features were synced
|
||||
if (totalNewFeatures > 0) {
|
||||
await firePmtilesRebuild("county-sync-complete", {
|
||||
county,
|
||||
uatCount: uats.length,
|
||||
newFeatures: totalNewFeatures,
|
||||
errors,
|
||||
});
|
||||
}
|
||||
|
||||
setTimeout(() => clearProgress(jobId), 6 * 3_600_000);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Unknown";
|
||||
setProgress({
|
||||
jobId,
|
||||
downloaded: 0,
|
||||
total: 100,
|
||||
status: "error",
|
||||
phase: "Eroare",
|
||||
message: msg,
|
||||
});
|
||||
await createAppNotification({
|
||||
type: "sync-error",
|
||||
title: `Sync ${county}: eroare generala`,
|
||||
message: msg,
|
||||
metadata: { county, jobId },
|
||||
});
|
||||
setTimeout(() => clearProgress(jobId), 3_600_000);
|
||||
} finally {
|
||||
g.__countySyncRunning = undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* PATCH /api/eterra/sync-rules/[id] — Update a sync rule
|
||||
* DELETE /api/eterra/sync-rules/[id] — Delete a sync rule
|
||||
*/
|
||||
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const VALID_FREQUENCIES = ["3x-daily", "daily", "weekly", "monthly", "manual"];
|
||||
|
||||
function computeNextDue(frequency: string, lastSyncAt: Date | null): Date | null {
|
||||
if (frequency === "manual") return null;
|
||||
const base = lastSyncAt ?? new Date();
|
||||
const ms: Record<string, number> = {
|
||||
"3x-daily": 8 * 3600_000,
|
||||
daily: 24 * 3600_000,
|
||||
weekly: 7 * 24 * 3600_000,
|
||||
monthly: 30 * 24 * 3600_000,
|
||||
};
|
||||
return ms[frequency] ? new Date(base.getTime() + ms[frequency]!) : null;
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const existing = await prisma.gisSyncRule.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: "Regula nu exista" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = (await req.json()) as Record<string, unknown>;
|
||||
|
||||
// Validate frequency if provided
|
||||
if (body.frequency && !VALID_FREQUENCIES.includes(body.frequency as string)) {
|
||||
return NextResponse.json({ error: "Frecventa invalida" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Build update data — only include provided fields
|
||||
const data: Record<string, unknown> = {};
|
||||
const fields = [
|
||||
"frequency", "syncTerenuri", "syncCladiri", "syncNoGeom", "syncEnrich",
|
||||
"priority", "enabled", "allowedHoursStart", "allowedHoursEnd",
|
||||
"allowedDays", "label",
|
||||
];
|
||||
for (const f of fields) {
|
||||
if (f in body) data[f] = body[f];
|
||||
}
|
||||
|
||||
// Recompute nextDueAt if frequency changed
|
||||
if (body.frequency) {
|
||||
data.nextDueAt = computeNextDue(
|
||||
body.frequency as string,
|
||||
existing.lastSyncAt,
|
||||
);
|
||||
}
|
||||
|
||||
// If enabled changed to true and no nextDueAt, compute it
|
||||
if (body.enabled === true && !existing.nextDueAt && !data.nextDueAt) {
|
||||
const freq = (body.frequency as string) ?? existing.frequency;
|
||||
data.nextDueAt = computeNextDue(freq, existing.lastSyncAt);
|
||||
}
|
||||
|
||||
const updated = await prisma.gisSyncRule.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
|
||||
return NextResponse.json({ rule: updated });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare server";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
await prisma.gisSyncRule.delete({ where: { id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare server";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* POST /api/eterra/sync-rules/bulk — Bulk operations on sync rules
|
||||
*
|
||||
* Actions:
|
||||
* - set-county-frequency: Create or update a county-level rule
|
||||
* - enable/disable: Toggle multiple rules by IDs
|
||||
* - delete: Delete multiple rules by IDs
|
||||
*/
|
||||
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const VALID_FREQUENCIES = ["3x-daily", "daily", "weekly", "monthly", "manual"];
|
||||
|
||||
function computeNextDue(frequency: string, lastSyncAt: Date | null): Date | null {
|
||||
if (frequency === "manual") return null;
|
||||
const base = lastSyncAt ?? new Date();
|
||||
const ms: Record<string, number> = {
|
||||
"3x-daily": 8 * 3600_000,
|
||||
daily: 24 * 3600_000,
|
||||
weekly: 7 * 24 * 3600_000,
|
||||
monthly: 30 * 24 * 3600_000,
|
||||
};
|
||||
return ms[frequency] ? new Date(base.getTime() + ms[frequency]!) : null;
|
||||
}
|
||||
|
||||
type BulkBody = {
|
||||
action: string;
|
||||
county?: string;
|
||||
frequency?: string;
|
||||
syncEnrich?: boolean;
|
||||
syncNoGeom?: boolean;
|
||||
ruleIds?: string[];
|
||||
};
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = (await req.json()) as BulkBody;
|
||||
|
||||
switch (body.action) {
|
||||
case "set-county-frequency": {
|
||||
if (!body.county || !body.frequency) {
|
||||
return NextResponse.json({ error: "county si frequency obligatorii" }, { status: 400 });
|
||||
}
|
||||
if (!VALID_FREQUENCIES.includes(body.frequency)) {
|
||||
return NextResponse.json({ error: "Frecventa invalida" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Upsert county-level rule
|
||||
const existing = await prisma.gisSyncRule.findFirst({
|
||||
where: { county: body.county, siruta: null },
|
||||
});
|
||||
|
||||
const rule = existing
|
||||
? await prisma.gisSyncRule.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
frequency: body.frequency,
|
||||
syncEnrich: body.syncEnrich ?? existing.syncEnrich,
|
||||
syncNoGeom: body.syncNoGeom ?? existing.syncNoGeom,
|
||||
nextDueAt: computeNextDue(body.frequency, existing.lastSyncAt),
|
||||
},
|
||||
})
|
||||
: await prisma.gisSyncRule.create({
|
||||
data: {
|
||||
county: body.county,
|
||||
frequency: body.frequency,
|
||||
syncEnrich: body.syncEnrich ?? false,
|
||||
syncNoGeom: body.syncNoGeom ?? false,
|
||||
nextDueAt: computeNextDue(body.frequency, null),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ rule, action: "set-county-frequency" });
|
||||
}
|
||||
|
||||
case "enable":
|
||||
case "disable": {
|
||||
if (!body.ruleIds?.length) {
|
||||
return NextResponse.json({ error: "ruleIds obligatorii" }, { status: 400 });
|
||||
}
|
||||
const result = await prisma.gisSyncRule.updateMany({
|
||||
where: { id: { in: body.ruleIds } },
|
||||
data: { enabled: body.action === "enable" },
|
||||
});
|
||||
return NextResponse.json({ updated: result.count, action: body.action });
|
||||
}
|
||||
|
||||
case "delete": {
|
||||
if (!body.ruleIds?.length) {
|
||||
return NextResponse.json({ error: "ruleIds obligatorii" }, { status: 400 });
|
||||
}
|
||||
const result = await prisma.gisSyncRule.deleteMany({
|
||||
where: { id: { in: body.ruleIds } },
|
||||
});
|
||||
return NextResponse.json({ deleted: result.count, action: "delete" });
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json({ error: `Actiune necunoscuta: ${body.action}` }, { status: 400 });
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare server";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* GET /api/eterra/sync-rules/global-default — Get global default frequency
|
||||
* PATCH /api/eterra/sync-rules/global-default — Set global default frequency
|
||||
*/
|
||||
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const NAMESPACE = "sync-management";
|
||||
const KEY = "global-default";
|
||||
const VALID_FREQUENCIES = ["3x-daily", "daily", "weekly", "monthly", "manual"];
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const row = await prisma.keyValueStore.findUnique({
|
||||
where: { namespace_key: { namespace: NAMESPACE, key: KEY } },
|
||||
});
|
||||
const val = row?.value as { frequency?: string } | null;
|
||||
return NextResponse.json({ frequency: val?.frequency ?? "monthly" });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare server";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(req: Request) {
|
||||
try {
|
||||
const body = (await req.json()) as { frequency?: string };
|
||||
if (!body.frequency || !VALID_FREQUENCIES.includes(body.frequency)) {
|
||||
return NextResponse.json({ error: "Frecventa invalida" }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.keyValueStore.upsert({
|
||||
where: { namespace_key: { namespace: NAMESPACE, key: KEY } },
|
||||
update: { value: { frequency: body.frequency } },
|
||||
create: { namespace: NAMESPACE, key: KEY, value: { frequency: body.frequency } },
|
||||
});
|
||||
|
||||
return NextResponse.json({ frequency: body.frequency });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare server";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* GET /api/eterra/sync-rules — List all sync rules, enriched with UAT/county names
|
||||
* POST /api/eterra/sync-rules — Create a new sync rule
|
||||
*/
|
||||
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const VALID_FREQUENCIES = ["3x-daily", "daily", "weekly", "monthly", "manual"] as const;
|
||||
|
||||
/** Compute nextDueAt from lastSyncAt + frequency interval */
|
||||
function computeNextDue(frequency: string, lastSyncAt: Date | null): Date | null {
|
||||
if (frequency === "manual") return null;
|
||||
const base = lastSyncAt ?? new Date();
|
||||
const ms = {
|
||||
"3x-daily": 8 * 3600_000,
|
||||
daily: 24 * 3600_000,
|
||||
weekly: 7 * 24 * 3600_000,
|
||||
monthly: 30 * 24 * 3600_000,
|
||||
}[frequency];
|
||||
if (!ms) return null;
|
||||
return new Date(base.getTime() + ms);
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const rules = await prisma.gisSyncRule.findMany({
|
||||
orderBy: [{ priority: "asc" }, { createdAt: "desc" }],
|
||||
});
|
||||
|
||||
// Enrich with UAT names for UAT-specific rules
|
||||
const sirutas = rules
|
||||
.map((r) => r.siruta)
|
||||
.filter((s): s is string => s != null);
|
||||
|
||||
const uatMap = new Map<string, string>();
|
||||
if (sirutas.length > 0) {
|
||||
const uats = await prisma.gisUat.findMany({
|
||||
where: { siruta: { in: sirutas } },
|
||||
select: { siruta: true, name: true },
|
||||
});
|
||||
for (const u of uats) uatMap.set(u.siruta, u.name);
|
||||
}
|
||||
|
||||
// For county rules, get UAT count per county
|
||||
const counties = rules
|
||||
.map((r) => r.county)
|
||||
.filter((c): c is string => c != null);
|
||||
|
||||
const countyCountMap = new Map<string, number>();
|
||||
if (counties.length > 0) {
|
||||
const counts = await prisma.gisUat.groupBy({
|
||||
by: ["county"],
|
||||
where: { county: { in: counties } },
|
||||
_count: true,
|
||||
});
|
||||
for (const c of counts) {
|
||||
if (c.county) countyCountMap.set(c.county, c._count);
|
||||
}
|
||||
}
|
||||
|
||||
const enriched = rules.map((r) => ({
|
||||
...r,
|
||||
uatName: r.siruta ? (uatMap.get(r.siruta) ?? null) : null,
|
||||
uatCount: r.county ? (countyCountMap.get(r.county) ?? 0) : r.siruta ? 1 : 0,
|
||||
}));
|
||||
|
||||
// Get global default
|
||||
const globalDefault = await prisma.keyValueStore.findUnique({
|
||||
where: { namespace_key: { namespace: "sync-management", key: "global-default" } },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
rules: enriched,
|
||||
globalDefault: (globalDefault?.value as { frequency?: string })?.frequency ?? "monthly",
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare server";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = (await req.json()) as {
|
||||
siruta?: string;
|
||||
county?: string;
|
||||
frequency?: string;
|
||||
syncTerenuri?: boolean;
|
||||
syncCladiri?: boolean;
|
||||
syncNoGeom?: boolean;
|
||||
syncEnrich?: boolean;
|
||||
priority?: number;
|
||||
enabled?: boolean;
|
||||
allowedHoursStart?: number | null;
|
||||
allowedHoursEnd?: number | null;
|
||||
allowedDays?: string | null;
|
||||
label?: string | null;
|
||||
};
|
||||
|
||||
if (!body.siruta && !body.county) {
|
||||
return NextResponse.json({ error: "Trebuie specificat siruta sau judetul" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!body.frequency || !VALID_FREQUENCIES.includes(body.frequency as typeof VALID_FREQUENCIES[number])) {
|
||||
return NextResponse.json(
|
||||
{ error: `Frecventa invalida. Valori permise: ${VALID_FREQUENCIES.join(", ")}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate siruta exists
|
||||
if (body.siruta) {
|
||||
const uat = await prisma.gisUat.findUnique({ where: { siruta: body.siruta } });
|
||||
if (!uat) {
|
||||
return NextResponse.json({ error: `UAT ${body.siruta} nu exista` }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
// Validate county has UATs
|
||||
if (body.county && !body.siruta) {
|
||||
const count = await prisma.gisUat.count({ where: { county: body.county } });
|
||||
if (count === 0) {
|
||||
return NextResponse.json({ error: `Niciun UAT in judetul ${body.county}` }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
// Check for existing rule with same scope
|
||||
const existing = await prisma.gisSyncRule.findFirst({
|
||||
where: {
|
||||
siruta: body.siruta ?? null,
|
||||
county: body.siruta ? null : (body.county ?? null),
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: "Exista deja o regula pentru acest scope", existingId: existing.id },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const nextDueAt = computeNextDue(body.frequency, null);
|
||||
|
||||
const rule = await prisma.gisSyncRule.create({
|
||||
data: {
|
||||
siruta: body.siruta ?? null,
|
||||
county: body.siruta ? null : (body.county ?? null),
|
||||
frequency: body.frequency,
|
||||
syncTerenuri: body.syncTerenuri ?? true,
|
||||
syncCladiri: body.syncCladiri ?? true,
|
||||
syncNoGeom: body.syncNoGeom ?? false,
|
||||
syncEnrich: body.syncEnrich ?? false,
|
||||
priority: body.priority ?? 5,
|
||||
enabled: body.enabled ?? true,
|
||||
allowedHoursStart: body.allowedHoursStart ?? null,
|
||||
allowedHoursEnd: body.allowedHoursEnd ?? null,
|
||||
allowedDays: body.allowedDays ?? null,
|
||||
label: body.label ?? null,
|
||||
nextDueAt,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ rule }, { status: 201 });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare server";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* GET /api/eterra/sync-rules/scheduler — Scheduler status
|
||||
*
|
||||
* Returns current scheduler state from KeyValueStore + computed stats.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Get scheduler state from KV (will be populated by the scheduler in Phase 2)
|
||||
const kvState = await prisma.keyValueStore.findUnique({
|
||||
where: {
|
||||
namespace_key: { namespace: "sync-management", key: "scheduler-state" },
|
||||
},
|
||||
});
|
||||
|
||||
// Compute rule stats
|
||||
const [totalRules, activeRules, dueNow, withErrors] = await Promise.all([
|
||||
prisma.gisSyncRule.count(),
|
||||
prisma.gisSyncRule.count({ where: { enabled: true } }),
|
||||
prisma.gisSyncRule.count({
|
||||
where: { enabled: true, nextDueAt: { lte: new Date() } },
|
||||
}),
|
||||
prisma.gisSyncRule.count({
|
||||
where: { lastSyncStatus: "error" },
|
||||
}),
|
||||
]);
|
||||
|
||||
// Frequency distribution
|
||||
const freqDist = await prisma.gisSyncRule.groupBy({
|
||||
by: ["frequency"],
|
||||
where: { enabled: true },
|
||||
_count: true,
|
||||
});
|
||||
|
||||
// County coverage
|
||||
const totalCounties = await prisma.gisUat.groupBy({
|
||||
by: ["county"],
|
||||
where: { county: { not: null } },
|
||||
_count: true,
|
||||
});
|
||||
|
||||
const countiesWithRules = await prisma.gisSyncRule.groupBy({
|
||||
by: ["county"],
|
||||
where: { county: { not: null } },
|
||||
_count: true,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
scheduler: kvState?.value ?? { status: "not-started" },
|
||||
stats: {
|
||||
totalRules,
|
||||
activeRules,
|
||||
dueNow,
|
||||
withErrors,
|
||||
frequencyDistribution: Object.fromEntries(
|
||||
freqDist.map((f) => [f.frequency, f._count]),
|
||||
),
|
||||
totalCounties: totalCounties.length,
|
||||
countiesWithRules: countiesWithRules.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare server";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* GET /api/eterra/tiles/orto?z=...&x=...&y=...
|
||||
*
|
||||
* Proxies eTerra ORTO2024 ortophoto tiles. Converts Web Mercator
|
||||
* tile coordinates to EPSG:3844 bbox and fetches from eTerra exportImage.
|
||||
* Requires active eTerra session (uses stored credentials).
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import proj4 from "proj4";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Register projections
|
||||
const EPSG_3844_DEF =
|
||||
"+proj=sterea +lat_0=46 +lon_0=25 +k=0.99975 +x_0=500000 +y_0=500000 +ellps=GRS80 +units=m +no_defs";
|
||||
proj4.defs("EPSG:3844", EPSG_3844_DEF);
|
||||
proj4.defs("EPSG:4326", "+proj=longlat +datum=WGS84 +no_defs");
|
||||
|
||||
const TILE_SIZE = 512;
|
||||
const ETERRA_BASE = "https://eterra.ancpi.ro/eterra";
|
||||
const ORTO_ENDPOINT = `${ETERRA_BASE}/api/map/rest/basemap/ORTO2024/exportImage`;
|
||||
|
||||
/** Convert tile z/x/y to WGS84 bounding box [west, south, east, north] */
|
||||
function tileToBbox(z: number, x: number, y: number): [number, number, number, number] {
|
||||
const n = Math.pow(2, z);
|
||||
const lonW = (x / n) * 360 - 180;
|
||||
const lonE = ((x + 1) / n) * 360 - 180;
|
||||
const latN = (Math.atan(Math.sinh(Math.PI * (1 - (2 * y) / n))) * 180) / Math.PI;
|
||||
const latS = (Math.atan(Math.sinh(Math.PI * (1 - (2 * (y + 1)) / n))) * 180) / Math.PI;
|
||||
return [lonW, latS, lonE, latN];
|
||||
}
|
||||
|
||||
/** Reproject WGS84 bbox to EPSG:3844 using all 4 corners (handles projection curvature) */
|
||||
function bboxTo3844(bbox4326: [number, number, number, number]): [number, number, number, number] {
|
||||
const [w, s, e, n] = bbox4326;
|
||||
// Project all 4 corners to handle non-linear projection
|
||||
const corners = [
|
||||
proj4("EPSG:4326", "EPSG:3844", [w, s]),
|
||||
proj4("EPSG:4326", "EPSG:3844", [e, s]),
|
||||
proj4("EPSG:4326", "EPSG:3844", [w, n]),
|
||||
proj4("EPSG:4326", "EPSG:3844", [e, n]),
|
||||
];
|
||||
const xs = corners.map((c) => c[0]!);
|
||||
const ys = corners.map((c) => c[1]!);
|
||||
return [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)];
|
||||
}
|
||||
|
||||
// Simple in-memory cookie cache for eTerra session
|
||||
let cachedCookie: string | null = null;
|
||||
let cookieExpiry = 0;
|
||||
|
||||
async function getEterraCookie(): Promise<string | null> {
|
||||
if (cachedCookie && Date.now() < cookieExpiry) return cachedCookie;
|
||||
|
||||
const username = process.env.ETERRA_USERNAME;
|
||||
const password = process.env.ETERRA_PASSWORD;
|
||||
if (!username || !password) return null;
|
||||
|
||||
try {
|
||||
const loginUrl = `${ETERRA_BASE}/api/authentication`;
|
||||
const body = new URLSearchParams({
|
||||
j_username: username,
|
||||
j_password: password,
|
||||
j_uuid: "undefined",
|
||||
j_isRevoked: "undefined",
|
||||
_spring_security_remember_me: "true",
|
||||
submit: "Login",
|
||||
});
|
||||
|
||||
const resp = await fetch(loginUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString(),
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
const setCookies = resp.headers.getSetCookie?.() ?? [];
|
||||
const jsessionId = setCookies
|
||||
.find((c) => c.startsWith("JSESSIONID="))
|
||||
?.split(";")[0];
|
||||
|
||||
if (jsessionId) {
|
||||
cachedCookie = jsessionId;
|
||||
cookieExpiry = Date.now() + 8 * 60 * 1000; // 8 min TTL
|
||||
return cachedCookie;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const z = parseInt(url.searchParams.get("z") ?? "", 10);
|
||||
const x = parseInt(url.searchParams.get("x") ?? "", 10);
|
||||
const y = parseInt(url.searchParams.get("y") ?? "", 10);
|
||||
|
||||
if (isNaN(z) || isNaN(x) || isNaN(y)) {
|
||||
return NextResponse.json({ error: "z, x, y required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Only serve tiles within Romania's approximate bounds (zoom >= 6)
|
||||
if (z < 6) {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
const bbox4326 = tileToBbox(z, x, y);
|
||||
const bbox3844 = bboxTo3844(bbox4326);
|
||||
|
||||
const cookie = await getEterraCookie();
|
||||
if (!cookie) {
|
||||
return NextResponse.json(
|
||||
{ error: "eTerra login esuat - verificati credentialele" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
f: "image",
|
||||
bbox: bbox3844.join(","),
|
||||
imageSR: "3844",
|
||||
bboxSR: "3844",
|
||||
size: `${TILE_SIZE},${TILE_SIZE}`,
|
||||
});
|
||||
|
||||
const imageResp = await fetch(`${ORTO_ENDPOINT}?${params}`, {
|
||||
headers: { Cookie: cookie },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
|
||||
if (!imageResp.ok) {
|
||||
// Return transparent tile for areas without coverage
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
const imageBuffer = await imageResp.arrayBuffer();
|
||||
|
||||
return new Response(imageBuffer, {
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
"Cache-Control": "public, max-age=86400", // cache 24h
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
}
|
||||
@@ -2,26 +2,75 @@ import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import { readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Feature count cache (expensive query, cached 5 min) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const gCache = globalThis as {
|
||||
__featureCountCache?: { map: Map<string, number>; ts: number };
|
||||
};
|
||||
|
||||
async function getCachedFeatureCounts(): Promise<Map<string, number>> {
|
||||
const TTL = 5 * 60 * 1000; // 5 minutes
|
||||
const now = Date.now();
|
||||
|
||||
if (gCache.__featureCountCache && now - gCache.__featureCountCache.ts < TTL) {
|
||||
return gCache.__featureCountCache.map;
|
||||
}
|
||||
|
||||
// Run in background if cache exists but expired (return stale, refresh async)
|
||||
if (gCache.__featureCountCache) {
|
||||
void refreshFeatureCounts();
|
||||
return gCache.__featureCountCache.map;
|
||||
}
|
||||
|
||||
// First call: must wait
|
||||
return refreshFeatureCounts();
|
||||
}
|
||||
|
||||
async function refreshFeatureCounts(): Promise<Map<string, number>> {
|
||||
try {
|
||||
const groups = await prisma.gisFeature.groupBy({
|
||||
by: ["siruta"],
|
||||
_count: { id: true },
|
||||
});
|
||||
const map = new Map<string, number>();
|
||||
for (const g of groups) {
|
||||
map.set(g.siruta, g._count.id);
|
||||
}
|
||||
gCache.__featureCountCache = { map, ts: Date.now() };
|
||||
return map;
|
||||
} catch {
|
||||
return gCache.__featureCountCache?.map ?? new Map();
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type EnrichedUat = {
|
||||
type UatResponse = {
|
||||
siruta: string;
|
||||
name: string;
|
||||
county: string;
|
||||
workspacePk: number;
|
||||
/** Number of GIS features synced locally for this UAT */
|
||||
localFeatures: number;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function populateWorkspaceCache(uats: EnrichedUat[]) {
|
||||
function populateWorkspaceCache(
|
||||
uats: Array<{ siruta: string; workspacePk: number }>,
|
||||
) {
|
||||
const wsGlobal = globalThis as {
|
||||
__eterraWorkspaceCache?: Map<string, number>;
|
||||
};
|
||||
@@ -35,24 +84,103 @@ function populateWorkspaceCache(uats: EnrichedUat[]) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove diacritics and uppercase for fuzzy name matching */
|
||||
function normalizeName(s: string): string {
|
||||
return s
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toUpperCase()
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Title-case: "SATU MARE" → "Satu Mare" */
|
||||
function titleCase(s: string): string {
|
||||
return s
|
||||
.toLowerCase()
|
||||
.replace(/(?:^|\s)\S/g, (ch) => ch.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a name from an eTerra nomenclature entry.
|
||||
* Tries multiple possible field names.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function extractName(entry: any): string {
|
||||
if (!entry || typeof entry !== "object") return "";
|
||||
for (const key of ["name", "nomenName", "label", "denumire", "NAME"]) {
|
||||
const val = entry[key];
|
||||
if (typeof val === "string" && val.trim()) return val.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a SIRUTA code from an eTerra nomenclature entry.
|
||||
* Tries multiple possible field names (nomenPk ≠ SIRUTA, but code might be).
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function extractCode(entry: any): string {
|
||||
if (!entry || typeof entry !== "object") return "";
|
||||
for (const key of [
|
||||
"code",
|
||||
"sirutaCode",
|
||||
"siruta",
|
||||
"externalCode",
|
||||
"cod",
|
||||
"CODE",
|
||||
]) {
|
||||
const val = entry[key];
|
||||
if (val != null) {
|
||||
const s = String(val).trim();
|
||||
if (s && /^\d+$/.test(s)) return s;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap a potentially nested response (Spring Boot Page format).
|
||||
* eTerra sometimes returns {content: [...]} instead of flat arrays.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function unwrapArray(data: any): any[] {
|
||||
if (Array.isArray(data)) return data;
|
||||
if (data && typeof data === "object") {
|
||||
if (Array.isArray(data.content)) return data.content;
|
||||
if (Array.isArray(data.data)) return data.data;
|
||||
if (Array.isArray(data.items)) return data.items;
|
||||
if (Array.isArray(data.results)) return data.results;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* GET /api/eterra/uats */
|
||||
/* */
|
||||
/* Always serves from local PostgreSQL (GisUat table). */
|
||||
/* Includes local GIS feature counts per UAT for the UI indicator. */
|
||||
/* No eTerra credentials needed — instant response. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// CRITICAL: select only needed fields — geometry column has huge polygon data
|
||||
const rows = await prisma.gisUat.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
select: { siruta: true, name: true, county: true, workspacePk: true },
|
||||
});
|
||||
|
||||
const uats: EnrichedUat[] = rows.map((r) => ({
|
||||
// Feature counts: use in-memory cache (refreshed every 5 min)
|
||||
// The groupBy query is expensive (~25s without cache) but the data
|
||||
// changes rarely (only when sync jobs run)
|
||||
const featureCounts = await getCachedFeatureCounts();
|
||||
|
||||
const uats: UatResponse[] = rows.map((r) => ({
|
||||
siruta: r.siruta,
|
||||
name: r.name,
|
||||
county: r.county ?? "",
|
||||
workspacePk: r.workspacePk ?? 0,
|
||||
localFeatures: featureCounts.get(r.siruta) ?? 0,
|
||||
}));
|
||||
|
||||
// Populate in-memory workspace cache for search route
|
||||
@@ -74,7 +202,9 @@ export async function GET() {
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* POST /api/eterra/uats */
|
||||
/* */
|
||||
/* Seed DB from static uat.json. */
|
||||
/* Seed or resync DB from static uat.json. */
|
||||
/* Uses upsert so it's safe to call repeatedly — new UATs are added, */
|
||||
/* existing names are updated, county/workspacePk are preserved. */
|
||||
/* eTerra nomenPk ≠ SIRUTA, so we cannot use the nomenclature API */
|
||||
/* for populating UAT data. uat.json has correct SIRUTA codes. */
|
||||
/* Workspace (county) PKs are resolved lazily via ArcGIS layer query */
|
||||
@@ -83,15 +213,6 @@ export async function GET() {
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
// Check if DB already has data
|
||||
const dbCount = await prisma.gisUat.count();
|
||||
if (dbCount > 0) {
|
||||
return NextResponse.json({
|
||||
synced: false,
|
||||
reason: "already-seeded",
|
||||
total: dbCount,
|
||||
});
|
||||
}
|
||||
|
||||
// Read uat.json from public/ directory
|
||||
let rawUats: Array<{ siruta: string; name: string }>;
|
||||
@@ -151,3 +272,242 @@ export async function POST() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* PATCH /api/eterra/uats */
|
||||
/* */
|
||||
/* Populate county names from eTerra nomenclature API. */
|
||||
/* */
|
||||
/* Strategy (two phases): */
|
||||
/* Phase 1: For UATs that already have workspacePk resolved, */
|
||||
/* use fetchCounties() → countyMap[workspacePk] → instant update. */
|
||||
/* Phase 2: For remaining UATs, enumerate counties → */
|
||||
/* fetchAdminUnitsByCounty() per county → match by code or name. */
|
||||
/* */
|
||||
/* Requires active eTerra session. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export async function PATCH() {
|
||||
try {
|
||||
// 1. Get eTerra credentials from session
|
||||
const session = getSessionCredentials();
|
||||
const username = String(
|
||||
session?.username || process.env.ETERRA_USERNAME || "",
|
||||
).trim();
|
||||
const password = String(
|
||||
session?.password || process.env.ETERRA_PASSWORD || "",
|
||||
).trim();
|
||||
|
||||
if (!username || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: "Conectează-te la eTerra mai întâi." },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const client = await EterraClient.create(username, password);
|
||||
|
||||
// 2. Fetch all counties from eTerra nomenclature
|
||||
const rawCounties = await client.fetchCounties();
|
||||
const counties = unwrapArray(rawCounties);
|
||||
const countyMap = new Map<number, string>(); // nomenPk → county name
|
||||
for (const c of counties) {
|
||||
const pk = Number(c?.nomenPk ?? 0);
|
||||
const name = extractName(c);
|
||||
if (pk > 0 && name) {
|
||||
countyMap.set(pk, titleCase(name));
|
||||
}
|
||||
}
|
||||
|
||||
if (countyMap.size === 0) {
|
||||
// Log raw response for debugging
|
||||
console.error(
|
||||
"[uats-patch] fetchCounties returned 0 counties. Raw sample:",
|
||||
JSON.stringify(rawCounties).slice(0, 500),
|
||||
);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Nu s-au putut obține județele din eTerra.",
|
||||
debug: {
|
||||
rawType: typeof rawCounties,
|
||||
isArray: Array.isArray(rawCounties),
|
||||
length: Array.isArray(rawCounties) ? rawCounties.length : null,
|
||||
sample: JSON.stringify(rawCounties).slice(0, 300),
|
||||
},
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[uats-patch] Fetched ${countyMap.size} counties from eTerra`);
|
||||
|
||||
// 3. Load all UATs from DB
|
||||
const allUats = await prisma.gisUat.findMany({
|
||||
select: { siruta: true, name: true, county: true, workspacePk: true },
|
||||
});
|
||||
|
||||
// Phase 1: instant fill for UATs that already have workspacePk
|
||||
const phase1Ops: Array<ReturnType<typeof prisma.gisUat.update>> = [];
|
||||
const needsCounty: Array<{ siruta: string; name: string }> = [];
|
||||
|
||||
for (const uat of allUats) {
|
||||
if (uat.county) continue; // already has county
|
||||
|
||||
if (uat.workspacePk && uat.workspacePk > 0) {
|
||||
const county = countyMap.get(uat.workspacePk);
|
||||
if (county) {
|
||||
phase1Ops.push(
|
||||
prisma.gisUat.update({
|
||||
where: { siruta: uat.siruta },
|
||||
data: { county },
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
needsCounty.push({ siruta: uat.siruta, name: uat.name });
|
||||
}
|
||||
|
||||
// Execute phase 1 in batches
|
||||
let phase1Updated = 0;
|
||||
for (let i = 0; i < phase1Ops.length; i += 100) {
|
||||
const batch = phase1Ops.slice(i, i + 100);
|
||||
await prisma.$transaction(batch);
|
||||
phase1Updated += batch.length;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[uats-patch] Phase 1: ${phase1Updated} updated via workspacePk. ` +
|
||||
`${needsCounty.length} remaining.`,
|
||||
);
|
||||
|
||||
// Phase 2: enumerate UATs per county from nomenclature, match by code or name
|
||||
// Build lookups
|
||||
const nameToSirutas = new Map<string, string[]>();
|
||||
const sirutaSet = new Set<string>();
|
||||
for (const u of needsCounty) {
|
||||
sirutaSet.add(u.siruta);
|
||||
const key = normalizeName(u.name);
|
||||
const arr = nameToSirutas.get(key);
|
||||
if (arr) arr.push(u.siruta);
|
||||
else nameToSirutas.set(key, [u.siruta]);
|
||||
}
|
||||
|
||||
let phase2Updated = 0;
|
||||
let codeMatches = 0;
|
||||
let nameMatches = 0;
|
||||
const matchedSirutas = new Set<string>();
|
||||
let loggedSample = false;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let sampleCounty: any = null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let sampleUat: any = null;
|
||||
let totalEterraUats = 0;
|
||||
|
||||
for (const [countyPk, countyName] of countyMap) {
|
||||
if (matchedSirutas.size >= needsCounty.length) break;
|
||||
|
||||
try {
|
||||
const rawUats = await client.fetchAdminUnitsByCounty(countyPk);
|
||||
const uats = unwrapArray(rawUats);
|
||||
|
||||
totalEterraUats += uats.length;
|
||||
|
||||
// Log first county's first UAT for debugging
|
||||
if (!loggedSample && uats.length > 0) {
|
||||
sampleUat = uats[0];
|
||||
sampleCounty = { pk: countyPk, name: countyName, uatCount: uats.length };
|
||||
console.log(
|
||||
`[uats-patch] Sample UAT from ${countyName} (${uats.length} UATs):`,
|
||||
JSON.stringify(uats[0]).slice(0, 500),
|
||||
);
|
||||
console.log(
|
||||
`[uats-patch] Sample UAT keys:`,
|
||||
Object.keys(uats[0] ?? {}),
|
||||
);
|
||||
loggedSample = true;
|
||||
}
|
||||
|
||||
for (const uat of uats) {
|
||||
// Strategy A: match by code (might be SIRUTA)
|
||||
const code = extractCode(uat);
|
||||
if (code && sirutaSet.has(code) && !matchedSirutas.has(code)) {
|
||||
matchedSirutas.add(code);
|
||||
await prisma.gisUat.update({
|
||||
where: { siruta: code },
|
||||
data: { county: countyName, workspacePk: countyPk },
|
||||
});
|
||||
phase2Updated++;
|
||||
codeMatches++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strategy B: match by normalized name
|
||||
const eterraName = extractName(uat);
|
||||
if (!eterraName) continue;
|
||||
|
||||
const key = normalizeName(eterraName);
|
||||
const sirutas = nameToSirutas.get(key);
|
||||
if (!sirutas || sirutas.length === 0) continue;
|
||||
|
||||
const siruta = sirutas.find((s) => !matchedSirutas.has(s));
|
||||
if (!siruta) continue;
|
||||
|
||||
matchedSirutas.add(siruta);
|
||||
await prisma.gisUat.update({
|
||||
where: { siruta },
|
||||
data: { county: countyName, workspacePk: countyPk },
|
||||
});
|
||||
phase2Updated++;
|
||||
nameMatches++;
|
||||
|
||||
if (sirutas.every((s) => matchedSirutas.has(s))) {
|
||||
nameToSirutas.delete(key);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[uats-patch] Failed to fetch UATs for county ${countyName} (pk=${countyPk}):`,
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const totalUpdated = phase1Updated + phase2Updated;
|
||||
const unmatched = needsCounty.length - phase2Updated;
|
||||
console.log(
|
||||
`[uats-patch] Phase 2: ${phase2Updated} (${codeMatches} by code, ${nameMatches} by name). ` +
|
||||
`Total: ${totalUpdated}. Unmatched: ${unmatched}.`,
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
updated: totalUpdated,
|
||||
phase1: phase1Updated,
|
||||
phase2: phase2Updated,
|
||||
codeMatches,
|
||||
nameMatches,
|
||||
totalCounties: countyMap.size,
|
||||
totalEterraUats,
|
||||
unmatched,
|
||||
// Include debug samples so we can see what eTerra returns
|
||||
debug: {
|
||||
sampleCounty,
|
||||
sampleUatKeys: sampleUat ? Object.keys(sampleUat) : null,
|
||||
sampleUat: sampleUat
|
||||
? JSON.parse(JSON.stringify(sampleUat).slice(0, 500))
|
||||
: null,
|
||||
sampleCountyRaw: counties[0]
|
||||
? {
|
||||
keys: Object.keys(counties[0]),
|
||||
nomenPk: counties[0].nomenPk,
|
||||
name: counties[0].name,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Eroare server";
|
||||
console.error("[uats-patch] Error:", message);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/eterra/uats/test-counties
|
||||
*
|
||||
* Diagnostic endpoint: tests eTerra nomenclature API and returns
|
||||
* raw results. Hit this from your browser to see exactly what
|
||||
* fetchCounties() and fetchAdminUnitsByCounty() return.
|
||||
*
|
||||
* Requires active eTerra session.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = getSessionCredentials();
|
||||
if (!session) {
|
||||
return NextResponse.json({
|
||||
error: "Nu ești conectat la eTerra.",
|
||||
step: "credentials",
|
||||
});
|
||||
}
|
||||
|
||||
const client = await EterraClient.create(session.username, session.password);
|
||||
|
||||
// Step 1: Fetch counties
|
||||
let rawCounties: unknown;
|
||||
try {
|
||||
rawCounties = await client.fetchCounties();
|
||||
} catch (err) {
|
||||
return NextResponse.json({
|
||||
error: "fetchCounties() a eșuat",
|
||||
step: "fetchCounties",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
const isArray = Array.isArray(rawCounties);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const countiesArr: any[] = isArray
|
||||
? rawCounties
|
||||
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
Array.isArray((rawCounties as any)?.content)
|
||||
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(rawCounties as any).content
|
||||
: [];
|
||||
|
||||
const countySummary = {
|
||||
rawType: typeof rawCounties,
|
||||
isArray,
|
||||
unwrappedLength: countiesArr.length,
|
||||
topLevelKeys:
|
||||
rawCounties && typeof rawCounties === "object" && !isArray
|
||||
? Object.keys(rawCounties)
|
||||
: null,
|
||||
firstCounty: countiesArr[0]
|
||||
? {
|
||||
allKeys: Object.keys(countiesArr[0]),
|
||||
raw: countiesArr[0],
|
||||
}
|
||||
: null,
|
||||
// Show first 5 counties
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
first5: countiesArr.slice(0, 5).map((c: any) => ({
|
||||
nomenPk: c?.nomenPk,
|
||||
name: c?.name,
|
||||
nomenName: c?.nomenName,
|
||||
label: c?.label,
|
||||
code: c?.code,
|
||||
allKeys: Object.keys(c ?? {}),
|
||||
})),
|
||||
};
|
||||
|
||||
// Step 2: Test fetchAdminUnitsByCounty with first county
|
||||
let uatSample = null;
|
||||
if (countiesArr.length > 0) {
|
||||
const firstCountyPk = countiesArr[0]?.nomenPk;
|
||||
if (firstCountyPk) {
|
||||
try {
|
||||
const rawUats =
|
||||
await client.fetchAdminUnitsByCounty(firstCountyPk);
|
||||
const uatsArr = Array.isArray(rawUats)
|
||||
? rawUats
|
||||
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
Array.isArray((rawUats as any)?.content)
|
||||
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(rawUats as any).content
|
||||
: [];
|
||||
|
||||
uatSample = {
|
||||
countyPk: firstCountyPk,
|
||||
countyName: countiesArr[0]?.name,
|
||||
rawType: typeof rawUats,
|
||||
isArray: Array.isArray(rawUats),
|
||||
unwrappedLength: uatsArr.length,
|
||||
topLevelKeys:
|
||||
rawUats && typeof rawUats === "object" && !Array.isArray(rawUats)
|
||||
? Object.keys(rawUats)
|
||||
: null,
|
||||
firstUat: uatsArr[0]
|
||||
? {
|
||||
allKeys: Object.keys(uatsArr[0]),
|
||||
raw: uatsArr[0],
|
||||
}
|
||||
: null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
first5: uatsArr.slice(0, 5).map((u: any) => ({
|
||||
nomenPk: u?.nomenPk,
|
||||
name: u?.name,
|
||||
nomenName: u?.nomenName,
|
||||
code: u?.code,
|
||||
sirutaCode: u?.sirutaCode,
|
||||
allKeys: Object.keys(u ?? {}),
|
||||
})),
|
||||
};
|
||||
} catch (err) {
|
||||
uatSample = {
|
||||
error: "fetchAdminUnitsByCounty() a eșuat",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
counties: countySummary,
|
||||
uatSample,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
error: "Eroare generală",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PrismaClient, Prisma } from "@prisma/client";
|
||||
import {
|
||||
isWeekendWindow,
|
||||
getWeekendSyncActivity,
|
||||
triggerForceSync,
|
||||
} from "@/modules/parcel-sync/services/weekend-deep-sync";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const g = globalThis as { __parcelSyncRunning?: boolean };
|
||||
|
||||
const KV_NAMESPACE = "parcel-sync-weekend";
|
||||
const KV_KEY = "queue-state";
|
||||
|
||||
type StepName = "sync_terenuri" | "sync_cladiri" | "import_nogeom" | "enrich";
|
||||
type StepStatus = "pending" | "done" | "error";
|
||||
|
||||
type CityState = {
|
||||
siruta: string;
|
||||
name: string;
|
||||
county: string;
|
||||
priority: number;
|
||||
steps: Record<StepName, StepStatus>;
|
||||
lastActivity?: string;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
type WeekendSyncState = {
|
||||
cities: CityState[];
|
||||
lastSessionDate?: string;
|
||||
totalSessions: number;
|
||||
completedCycles: number;
|
||||
};
|
||||
|
||||
const FRESH_STEPS: Record<StepName, StepStatus> = {
|
||||
sync_terenuri: "pending",
|
||||
sync_cladiri: "pending",
|
||||
import_nogeom: "pending",
|
||||
enrich: "pending",
|
||||
};
|
||||
|
||||
const DEFAULT_CITIES: Omit<CityState, "steps">[] = [
|
||||
{ siruta: "54975", name: "Cluj-Napoca", county: "Cluj", priority: 1 },
|
||||
{ siruta: "32394", name: "Bistri\u021Ba", county: "Bistri\u021Ba-N\u0103s\u0103ud", priority: 1 },
|
||||
{ siruta: "114319", name: "T\u00E2rgu Mure\u0219", county: "Mure\u0219", priority: 2 },
|
||||
{ siruta: "139704", name: "Zal\u0103u", county: "S\u0103laj", priority: 2 },
|
||||
{ siruta: "26564", name: "Oradea", county: "Bihor", priority: 2 },
|
||||
{ siruta: "9262", name: "Arad", county: "Arad", priority: 2 },
|
||||
{ siruta: "155243", name: "Timi\u0219oara", county: "Timi\u0219", priority: 2 },
|
||||
{ siruta: "143450", name: "Sibiu", county: "Sibiu", priority: 2 },
|
||||
{ siruta: "40198", name: "Bra\u0219ov", county: "Bra\u0219ov", priority: 2 },
|
||||
];
|
||||
|
||||
/** Initialize state with default cities if not present in DB */
|
||||
async function getOrCreateState(): Promise<WeekendSyncState> {
|
||||
const row = await prisma.keyValueStore.findUnique({
|
||||
where: { namespace_key: { namespace: KV_NAMESPACE, key: KV_KEY } },
|
||||
});
|
||||
if (row?.value && typeof row.value === "object") {
|
||||
return row.value as unknown as WeekendSyncState;
|
||||
}
|
||||
// First access — initialize with defaults
|
||||
const state: WeekendSyncState = {
|
||||
cities: DEFAULT_CITIES.map((c) => ({ ...c, steps: { ...FRESH_STEPS } })),
|
||||
totalSessions: 0,
|
||||
completedCycles: 0,
|
||||
};
|
||||
await prisma.keyValueStore.upsert({
|
||||
where: { namespace_key: { namespace: KV_NAMESPACE, key: KV_KEY } },
|
||||
update: { value: state as unknown as Prisma.InputJsonValue },
|
||||
create: {
|
||||
namespace: KV_NAMESPACE,
|
||||
key: KV_KEY,
|
||||
value: state as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/eterra/weekend-sync
|
||||
* Returns the current queue state.
|
||||
*/
|
||||
export async function GET() {
|
||||
// Auth handled by middleware (route is not excluded)
|
||||
const state = await getOrCreateState();
|
||||
const sirutas = state.cities.map((c) => c.siruta);
|
||||
|
||||
const counts = await prisma.gisFeature.groupBy({
|
||||
by: ["siruta", "layerId"],
|
||||
where: { siruta: { in: sirutas } },
|
||||
_count: { id: true },
|
||||
});
|
||||
|
||||
const enrichedCounts = await prisma.gisFeature.groupBy({
|
||||
by: ["siruta"],
|
||||
where: { siruta: { in: sirutas }, enrichedAt: { not: null } },
|
||||
_count: { id: true },
|
||||
});
|
||||
|
||||
const enrichedMap = new Map(enrichedCounts.map((e) => [e.siruta, e._count.id]));
|
||||
|
||||
type CityStats = {
|
||||
terenuri: number;
|
||||
cladiri: number;
|
||||
total: number;
|
||||
enriched: number;
|
||||
};
|
||||
const statsMap = new Map<string, CityStats>();
|
||||
|
||||
for (const c of counts) {
|
||||
const existing = statsMap.get(c.siruta) ?? { terenuri: 0, cladiri: 0, total: 0, enriched: 0 };
|
||||
existing.total += c._count.id;
|
||||
if (c.layerId === "TERENURI_ACTIVE") existing.terenuri = c._count.id;
|
||||
if (c.layerId === "CLADIRI_ACTIVE") existing.cladiri = c._count.id;
|
||||
existing.enriched = enrichedMap.get(c.siruta) ?? 0;
|
||||
statsMap.set(c.siruta, existing);
|
||||
}
|
||||
|
||||
const citiesWithStats = state.cities.map((c) => ({
|
||||
...c,
|
||||
dbStats: statsMap.get(c.siruta) ?? { terenuri: 0, cladiri: 0, total: 0, enriched: 0 },
|
||||
}));
|
||||
|
||||
// Determine live sync status
|
||||
const running = !!g.__parcelSyncRunning;
|
||||
const activity = getWeekendSyncActivity();
|
||||
const inWindow = isWeekendWindow();
|
||||
const hasErrors = state.cities.some((c) =>
|
||||
(Object.values(c.steps) as StepStatus[]).some((s) => s === "error"),
|
||||
);
|
||||
|
||||
type SyncStatus = "running" | "error" | "waiting" | "idle";
|
||||
let syncStatus: SyncStatus = "idle";
|
||||
if (running) syncStatus = "running";
|
||||
else if (hasErrors) syncStatus = "error";
|
||||
else if (inWindow) syncStatus = "waiting";
|
||||
|
||||
return NextResponse.json({
|
||||
state: { ...state, cities: citiesWithStats },
|
||||
syncStatus,
|
||||
currentActivity: activity,
|
||||
inWeekendWindow: inWindow,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/eterra/weekend-sync
|
||||
* Modify the queue: add/remove cities, reset steps, change priority.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
// Auth handled by middleware (route is not excluded)
|
||||
const body = (await request.json()) as {
|
||||
action: "add" | "remove" | "reset" | "reset_all" | "set_priority" | "trigger";
|
||||
siruta?: string;
|
||||
name?: string;
|
||||
county?: string;
|
||||
priority?: number;
|
||||
onlySteps?: string[];
|
||||
};
|
||||
|
||||
// Trigger is handled separately — starts sync immediately
|
||||
if (body.action === "trigger") {
|
||||
const validSteps = ["sync_terenuri", "sync_cladiri", "import_nogeom", "enrich"] as const;
|
||||
const onlySteps = body.onlySteps?.filter((s): s is (typeof validSteps)[number] =>
|
||||
(validSteps as readonly string[]).includes(s),
|
||||
);
|
||||
const result = await triggerForceSync(
|
||||
onlySteps && onlySteps.length > 0 ? { onlySteps } : undefined,
|
||||
);
|
||||
if (!result.started) {
|
||||
return NextResponse.json(
|
||||
{ error: result.reason },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ ok: true, message: "Sincronizare pornita" });
|
||||
}
|
||||
|
||||
const state = await getOrCreateState();
|
||||
|
||||
switch (body.action) {
|
||||
case "add": {
|
||||
if (!body.siruta || !body.name) {
|
||||
return NextResponse.json(
|
||||
{ error: "siruta si name sunt obligatorii" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (state.cities.some((c) => c.siruta === body.siruta)) {
|
||||
return NextResponse.json(
|
||||
{ error: `${body.name} (${body.siruta}) e deja in coada` },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
state.cities.push({
|
||||
siruta: body.siruta,
|
||||
name: body.name,
|
||||
county: body.county ?? "",
|
||||
priority: body.priority ?? 3,
|
||||
steps: { ...FRESH_STEPS },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "remove": {
|
||||
state.cities = state.cities.filter((c) => c.siruta !== body.siruta);
|
||||
break;
|
||||
}
|
||||
case "reset": {
|
||||
const city = state.cities.find((c) => c.siruta === body.siruta);
|
||||
if (city) {
|
||||
city.steps = { ...FRESH_STEPS };
|
||||
city.errorMessage = undefined;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "reset_all": {
|
||||
for (const city of state.cities) {
|
||||
city.steps = { ...FRESH_STEPS };
|
||||
city.errorMessage = undefined;
|
||||
}
|
||||
state.completedCycles = 0;
|
||||
break;
|
||||
}
|
||||
case "set_priority": {
|
||||
const city = state.cities.find((c) => c.siruta === body.siruta);
|
||||
if (city && body.priority != null) {
|
||||
city.priority = body.priority;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.keyValueStore.upsert({
|
||||
where: { namespace_key: { namespace: KV_NAMESPACE, key: KV_KEY } },
|
||||
update: { value: state as unknown as Prisma.InputJsonValue },
|
||||
create: {
|
||||
namespace: KV_NAMESPACE,
|
||||
key: KV_KEY,
|
||||
value: state as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, cities: state.cities.length });
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* GET /api/geoportal/boundary-check?siruta=57582
|
||||
*
|
||||
* Spatial cross-check: finds parcels that geometrically fall within the
|
||||
* given UAT boundary but are registered under a DIFFERENT siruta.
|
||||
*
|
||||
* Also detects the reverse: parcels registered in this UAT whose centroid
|
||||
* falls outside its boundary (edge parcels).
|
||||
*
|
||||
* Returns GeoJSON FeatureCollection in EPSG:4326 (WGS84) for direct
|
||||
* map overlay.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type RawRow = {
|
||||
id: string;
|
||||
siruta: string;
|
||||
object_id: number;
|
||||
cadastral_ref: string | null;
|
||||
area_value: number | null;
|
||||
layer_id: string;
|
||||
mismatch_type: string;
|
||||
geojson: string;
|
||||
};
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const siruta = req.nextUrl.searchParams.get("siruta");
|
||||
if (!siruta) {
|
||||
return NextResponse.json({ error: "siruta required" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Foreign parcels: registered in OTHER UATs but geometrically overlap this UAT
|
||||
const foreign = await prisma.$queryRaw`
|
||||
SELECT
|
||||
f.id,
|
||||
f.siruta,
|
||||
f."objectId" AS object_id,
|
||||
f."cadastralRef" AS cadastral_ref,
|
||||
f."areaValue" AS area_value,
|
||||
f."layerId" AS layer_id,
|
||||
'foreign' AS mismatch_type,
|
||||
ST_AsGeoJSON(ST_Transform(f.geom, 4326)) AS geojson
|
||||
FROM "GisFeature" f
|
||||
JOIN "GisUat" u ON u.siruta = ${siruta}
|
||||
WHERE f.siruta != ${siruta}
|
||||
AND ST_Intersects(f.geom, u.geom)
|
||||
AND (f."layerId" LIKE 'TERENURI%' OR f."layerId" LIKE 'CADGEN_LAND%')
|
||||
AND f.geom IS NOT NULL
|
||||
LIMIT 500
|
||||
` as RawRow[];
|
||||
|
||||
// 2. Edge parcels: registered in this UAT but centroid falls outside boundary
|
||||
const edge = await prisma.$queryRaw`
|
||||
SELECT
|
||||
f.id,
|
||||
f.siruta,
|
||||
f."objectId" AS object_id,
|
||||
f."cadastralRef" AS cadastral_ref,
|
||||
f."areaValue" AS area_value,
|
||||
f."layerId" AS layer_id,
|
||||
'edge' AS mismatch_type,
|
||||
ST_AsGeoJSON(ST_Transform(f.geom, 4326)) AS geojson
|
||||
FROM "GisFeature" f
|
||||
JOIN "GisUat" u ON u.siruta = f.siruta AND u.siruta = ${siruta}
|
||||
WHERE NOT ST_Contains(u.geom, ST_Centroid(f.geom))
|
||||
AND (f."layerId" LIKE 'TERENURI%' OR f."layerId" LIKE 'CADGEN_LAND%')
|
||||
AND f.geom IS NOT NULL
|
||||
LIMIT 500
|
||||
` as RawRow[];
|
||||
|
||||
const allRows = [...foreign, ...edge];
|
||||
|
||||
// Build GeoJSON FeatureCollection
|
||||
const features = allRows
|
||||
.map((row) => {
|
||||
try {
|
||||
const geometry = JSON.parse(row.geojson) as GeoJSON.Geometry;
|
||||
return {
|
||||
type: "Feature" as const,
|
||||
geometry,
|
||||
properties: {
|
||||
id: row.id,
|
||||
siruta: row.siruta,
|
||||
object_id: row.object_id,
|
||||
cadastral_ref: row.cadastral_ref,
|
||||
area_value: row.area_value,
|
||||
layer_id: row.layer_id,
|
||||
mismatch_type: row.mismatch_type,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return NextResponse.json({
|
||||
type: "FeatureCollection",
|
||||
features,
|
||||
summary: {
|
||||
foreign: foreign.length,
|
||||
edge: edge.length,
|
||||
total: allRows.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* GET /api/geoportal/cf-status?nrCad=...
|
||||
*
|
||||
* Checks if a CF extract exists for a given cadastral number.
|
||||
* Returns download info if available.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const nrCad = url.searchParams.get("nrCad")?.trim();
|
||||
|
||||
if (!nrCad) {
|
||||
return NextResponse.json({ error: "nrCad obligatoriu" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Find the latest completed CF extract for this cadastral number
|
||||
const extract = await prisma.cfExtract.findFirst({
|
||||
where: {
|
||||
nrCadastral: nrCad,
|
||||
status: "completed",
|
||||
minioPath: { not: "" },
|
||||
},
|
||||
orderBy: { completedAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
nrCadastral: true,
|
||||
nrCF: true,
|
||||
status: true,
|
||||
minioPath: true,
|
||||
documentName: true,
|
||||
completedAt: true,
|
||||
expiresAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!extract || !extract.minioPath) {
|
||||
return NextResponse.json({ available: false });
|
||||
}
|
||||
|
||||
const expired = extract.expiresAt && new Date(extract.expiresAt) < new Date();
|
||||
|
||||
return NextResponse.json({
|
||||
available: !expired,
|
||||
expired: !!expired,
|
||||
id: extract.id,
|
||||
nrCF: extract.nrCF,
|
||||
documentName: extract.documentName,
|
||||
completedAt: extract.completedAt?.toISOString(),
|
||||
downloadUrl: `/api/ancpi/download?id=${extract.id}`,
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* POST /api/geoportal/enrich
|
||||
*
|
||||
* Per-parcel enrichment — calls the proven /api/eterra/search internally
|
||||
* and saves the result to GisFeature.enrichment.
|
||||
*
|
||||
* Body: { featureId: string } or { siruta: string, objectId: number }
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = (await req.json()) as { featureId?: string; siruta?: string; objectId?: number };
|
||||
|
||||
// Find feature
|
||||
let feature;
|
||||
if (body.featureId) {
|
||||
feature = await prisma.gisFeature.findUnique({
|
||||
where: { id: body.featureId },
|
||||
select: { id: true, objectId: true, siruta: true, cadastralRef: true, areaValue: true },
|
||||
});
|
||||
} else if (body.siruta && body.objectId) {
|
||||
feature = await prisma.gisFeature.findFirst({
|
||||
where: { siruta: body.siruta, objectId: body.objectId },
|
||||
select: { id: true, objectId: true, siruta: true, cadastralRef: true, areaValue: true },
|
||||
});
|
||||
}
|
||||
|
||||
if (!feature) {
|
||||
return NextResponse.json({ error: "Parcela negasita in DB" }, { status: 404 });
|
||||
}
|
||||
|
||||
const cadRef = feature.cadastralRef ?? "";
|
||||
if (!cadRef) {
|
||||
return NextResponse.json({ error: "Parcela fara numar cadastral" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Call the proven /api/eterra/search endpoint internally
|
||||
const headersList = await headers();
|
||||
const cookie = headersList.get("cookie") ?? "";
|
||||
const origin = req.headers.get("origin") ?? headersList.get("host") ?? "localhost:3000";
|
||||
const protocol = origin.includes("localhost") ? "http" : "https";
|
||||
const baseUrl = origin.startsWith("http") ? origin : `${protocol}://${origin}`;
|
||||
|
||||
const searchResp = await fetch(`${baseUrl}/api/eterra/search`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", cookie },
|
||||
body: JSON.stringify({ siruta: feature.siruta, search: cadRef }),
|
||||
});
|
||||
|
||||
if (!searchResp.ok) {
|
||||
const err = await searchResp.json().catch(() => ({}));
|
||||
return NextResponse.json(
|
||||
{ error: (err as Record<string, string>).error ?? `eTerra search esuat (${searchResp.status})` },
|
||||
{ status: searchResp.status }
|
||||
);
|
||||
}
|
||||
|
||||
const searchData = await searchResp.json() as {
|
||||
results: Array<{
|
||||
nrCad: string; nrCF: string; nrCFVechi: string; nrTopo: string;
|
||||
intravilan: string; categorieFolosinta: string; adresa: string;
|
||||
proprietari: string; proprietariActuali: string; proprietariVechi: string;
|
||||
suprafata: number | null; solicitant: string; immovablePk: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const match = searchData.results?.[0];
|
||||
if (!match) {
|
||||
return NextResponse.json({ error: "Parcela negasita in registrul eTerra" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Building cross-ref: check CLADIRI_ACTIVE in local DB for this parcel
|
||||
let hasBuilding = 0;
|
||||
let buildLegal = 0;
|
||||
const baseCad = cadRef.includes("-") ? cadRef.split("-")[0]! : cadRef;
|
||||
if (baseCad) {
|
||||
const cladiri = await prisma.gisFeature.findMany({
|
||||
where: {
|
||||
layerId: "CLADIRI_ACTIVE",
|
||||
siruta: feature.siruta,
|
||||
OR: [
|
||||
{ cadastralRef: { startsWith: baseCad + "-" } },
|
||||
{ cadastralRef: baseCad },
|
||||
],
|
||||
},
|
||||
select: { attributes: true },
|
||||
});
|
||||
for (const c of cladiri) {
|
||||
const attrs = c.attributes as Record<string, unknown>;
|
||||
hasBuilding = 1;
|
||||
if (
|
||||
Number(attrs.IS_LEGAL ?? 0) === 1 ||
|
||||
String(attrs.IS_LEGAL ?? "").toLowerCase() === "true"
|
||||
) {
|
||||
buildLegal = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to enrichment format (same as enrichFeatures uses)
|
||||
const enrichment = {
|
||||
NR_CAD: match.nrCad || cadRef,
|
||||
NR_CF: match.nrCF || "",
|
||||
NR_CF_VECHI: match.nrCFVechi || "",
|
||||
NR_TOPO: match.nrTopo || "",
|
||||
ADRESA: match.adresa || "",
|
||||
PROPRIETARI: match.proprietariActuali || match.proprietari || "",
|
||||
PROPRIETARI_VECHI: match.proprietariVechi || "",
|
||||
SUPRAFATA_2D: match.suprafata ?? feature.areaValue ?? "",
|
||||
SUPRAFATA_R: match.suprafata ? Math.round(match.suprafata) : (feature.areaValue ? Math.round(feature.areaValue) : ""),
|
||||
SOLICITANT: match.solicitant || "",
|
||||
INTRAVILAN: match.intravilan || "",
|
||||
CATEGORIE_FOLOSINTA: match.categorieFolosinta || "",
|
||||
HAS_BUILDING: hasBuilding,
|
||||
BUILD_LEGAL: buildLegal,
|
||||
};
|
||||
|
||||
// Persist
|
||||
await prisma.gisFeature.update({
|
||||
where: { id: feature.id },
|
||||
data: { enrichment: enrichment as object, enrichedAt: new Date() },
|
||||
});
|
||||
|
||||
return NextResponse.json({ status: "ok", message: "Parcela imbogatita cu succes", enrichment });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* POST /api/geoportal/export
|
||||
*
|
||||
* Exports selected GIS features as GeoJSON, DXF, or GeoPackage.
|
||||
* Body: { ids: string[], format: "geojson" | "dxf" | "gpkg" }
|
||||
*
|
||||
* - GeoJSON: always works (pure JS)
|
||||
* - DXF/GPKG: requires ogr2ogr (available in Docker image via gdal-tools)
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import { writeFile, readFile, unlink, mkdtemp } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
type ExportRequest = {
|
||||
ids: string[];
|
||||
format: "geojson" | "dxf" | "gpkg";
|
||||
};
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let tmpDir: string | null = null;
|
||||
|
||||
try {
|
||||
const body = (await req.json()) as ExportRequest;
|
||||
const { ids, format } = body;
|
||||
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Selecteaza cel putin o parcela" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!["geojson", "dxf", "gpkg"].includes(format)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Format invalid. Optiuni: geojson, dxf, gpkg" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// The IDs from the selection are objectId strings
|
||||
// Query features by objectId (converted to int)
|
||||
const objectIds = ids.map((id) => parseInt(id, 10)).filter((n) => !isNaN(n));
|
||||
|
||||
if (objectIds.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Niciun ID valid in selectie" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const features = await prisma.gisFeature.findMany({
|
||||
where: {
|
||||
objectId: { in: objectIds },
|
||||
geometry: { not: Prisma.JsonNull },
|
||||
},
|
||||
select: {
|
||||
objectId: true,
|
||||
cadastralRef: true,
|
||||
areaValue: true,
|
||||
attributes: true,
|
||||
geometry: true,
|
||||
enrichment: true,
|
||||
layerId: true,
|
||||
siruta: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (features.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Niciun feature cu geometrie gasit" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Build GeoJSON FeatureCollection
|
||||
const geojson = {
|
||||
type: "FeatureCollection" as const,
|
||||
crs: {
|
||||
type: "name",
|
||||
properties: { name: "urn:ogc:def:crs:EPSG::3844" },
|
||||
},
|
||||
features: features.map((f) => {
|
||||
const enrichment = (f.enrichment ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
type: "Feature" as const,
|
||||
geometry: f.geometry as Record<string, unknown>,
|
||||
properties: {
|
||||
objectId: f.objectId,
|
||||
cadastralRef: f.cadastralRef,
|
||||
areaValue: f.areaValue,
|
||||
layerId: f.layerId,
|
||||
siruta: f.siruta,
|
||||
NR_CAD: enrichment.NR_CAD ?? "",
|
||||
NR_CF: enrichment.NR_CF ?? "",
|
||||
PROPRIETARI: enrichment.PROPRIETARI ?? "",
|
||||
SUPRAFATA: enrichment.SUPRAFATA_2D ?? "",
|
||||
INTRAVILAN: enrichment.INTRAVILAN ?? "",
|
||||
CATEGORIE: enrichment.CATEGORIE_FOLOSINTA ?? "",
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const timestamp = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// GeoJSON — return directly
|
||||
if (format === "geojson") {
|
||||
const filename = `parcele_${timestamp}.geojson`;
|
||||
return new Response(JSON.stringify(geojson, null, 2), {
|
||||
headers: {
|
||||
"Content-Type": "application/geo+json",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// DXF or GPKG — use ogr2ogr
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "geoportal-export-"));
|
||||
const inputPath = join(tmpDir, "input.geojson");
|
||||
await writeFile(inputPath, JSON.stringify(geojson));
|
||||
|
||||
const ext = format === "dxf" ? "dxf" : "gpkg";
|
||||
const outputPath = join(tmpDir, `output.${ext}`);
|
||||
const ogrFormat = format === "dxf" ? "DXF" : "GPKG";
|
||||
|
||||
// DXF: reproject to WGS84 (-s_srs + -t_srs). GPKG: assign CRS only (-a_srs).
|
||||
const ogrArgs = format === "dxf"
|
||||
? ["-f", ogrFormat, outputPath, inputPath, "-s_srs", "EPSG:3844", "-t_srs", "EPSG:4326"]
|
||||
: ["-f", ogrFormat, outputPath, inputPath, "-a_srs", "EPSG:3844"];
|
||||
|
||||
try {
|
||||
await execFileAsync("ogr2ogr", ogrArgs, { timeout: 30_000 });
|
||||
} catch (err: unknown) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
// ogr2ogr not available (local dev without GDAL)
|
||||
if (errMsg.includes("ENOENT") || errMsg.includes("not found")) {
|
||||
return NextResponse.json(
|
||||
{ error: `Export ${format.toUpperCase()} disponibil doar in productie (Docker cu GDAL)` },
|
||||
{ status: 501 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: `ogr2ogr a esuat: ${errMsg}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const outputBuffer = await readFile(outputPath);
|
||||
const filename = `parcele_${timestamp}.${ext}`;
|
||||
|
||||
const contentType =
|
||||
format === "dxf"
|
||||
? "application/dxf"
|
||||
: "application/geopackage+sqlite3";
|
||||
|
||||
// Clean up temp files (best effort)
|
||||
cleanup(tmpDir);
|
||||
tmpDir = null;
|
||||
|
||||
return new Response(outputBuffer, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (tmpDir) cleanup(tmpDir);
|
||||
const msg = error instanceof Error ? error.message : "Eroare la export";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanup(dir: string) {
|
||||
try {
|
||||
const { readdir } = await import("fs/promises");
|
||||
const files = await readdir(dir);
|
||||
for (const f of files) {
|
||||
await unlink(join(dir, f)).catch(() => {});
|
||||
}
|
||||
const { rmdir } = await import("fs/promises");
|
||||
await rmdir(dir).catch(() => {});
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* GET /api/geoportal/feature?objectId=...&siruta=...&sourceLayer=...
|
||||
*
|
||||
* Returns a single GIS feature with enrichment data for the info panel.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const objectId = url.searchParams.get("objectId");
|
||||
const siruta = url.searchParams.get("siruta");
|
||||
const sourceLayer = url.searchParams.get("sourceLayer") ?? "";
|
||||
|
||||
if (!objectId || !siruta) {
|
||||
return NextResponse.json(
|
||||
{ error: "Parametri lipsa: objectId si siruta sunt obligatorii" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// UAT features come from GisUat table
|
||||
if (sourceLayer === "gis_uats") {
|
||||
const uat = await prisma.gisUat.findUnique({
|
||||
where: { siruta },
|
||||
select: {
|
||||
siruta: true,
|
||||
name: true,
|
||||
county: true,
|
||||
areaValue: true,
|
||||
workspacePk: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!uat) {
|
||||
return NextResponse.json({ error: "UAT negasit" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
feature: {
|
||||
id: uat.siruta,
|
||||
layerId: "LIMITE_UAT",
|
||||
siruta: uat.siruta,
|
||||
objectId: 0,
|
||||
cadastralRef: null,
|
||||
areaValue: uat.areaValue,
|
||||
enrichment: null,
|
||||
enrichedAt: null,
|
||||
extra: {
|
||||
name: uat.name,
|
||||
county: uat.county,
|
||||
workspacePk: uat.workspacePk,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// GisFeature (parcels, buildings)
|
||||
const objId = parseInt(objectId, 10);
|
||||
if (isNaN(objId)) {
|
||||
return NextResponse.json({ error: "objectId invalid" }, { status: 400 });
|
||||
}
|
||||
|
||||
const feature = await prisma.gisFeature.findFirst({
|
||||
where: {
|
||||
objectId: objId,
|
||||
siruta,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
layerId: true,
|
||||
siruta: true,
|
||||
objectId: true,
|
||||
cadastralRef: true,
|
||||
areaValue: true,
|
||||
enrichment: true,
|
||||
enrichedAt: true,
|
||||
inspireId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!feature) {
|
||||
return NextResponse.json(
|
||||
{ error: "Feature negasit in baza de date" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
feature: {
|
||||
id: feature.id,
|
||||
layerId: feature.layerId,
|
||||
siruta: feature.siruta,
|
||||
objectId: feature.objectId,
|
||||
cadastralRef: feature.cadastralRef,
|
||||
areaValue: feature.areaValue,
|
||||
enrichment: feature.enrichment as Record<string, unknown> | null,
|
||||
enrichedAt: feature.enrichedAt?.toISOString() ?? null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare server";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* GET /api/geoportal/monitor — tile infrastructure status
|
||||
* POST /api/geoportal/monitor — trigger actions (rebuild, warm-cache)
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { firePmtilesRebuild } from "@/modules/parcel-sync/services/pmtiles-webhook";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const TILE_CACHE_INTERNAL = "http://tile-cache:80";
|
||||
const MARTIN_INTERNAL = "http://martin:3000";
|
||||
const PMTILES_URL = process.env.NEXT_PUBLIC_PMTILES_URL || "";
|
||||
// Server-side fetch needs absolute URL — resolve relative paths through tile-cache
|
||||
const PMTILES_FETCH_URL = PMTILES_URL.startsWith("/")
|
||||
? `${TILE_CACHE_INTERNAL}${PMTILES_URL.replace(/^\/tiles/, "")}`
|
||||
: PMTILES_URL;
|
||||
const N8N_WEBHOOK_URL = process.env.N8N_WEBHOOK_URL || "";
|
||||
|
||||
type NginxStatus = {
|
||||
activeConnections: number;
|
||||
accepts: number;
|
||||
handled: number;
|
||||
requests: number;
|
||||
reading: number;
|
||||
writing: number;
|
||||
waiting: number;
|
||||
};
|
||||
|
||||
function parseNginxStatus(text: string): NginxStatus {
|
||||
const lines = text.trim().split("\n");
|
||||
const active = parseInt(lines[0]?.match(/\d+/)?.[0] ?? "0", 10);
|
||||
const counts = lines[2]?.trim().split(/\s+/).map(Number) ?? [0, 0, 0];
|
||||
const rw = lines[3]?.match(/\d+/g)?.map(Number) ?? [0, 0, 0];
|
||||
return {
|
||||
activeConnections: active,
|
||||
accepts: counts[0] ?? 0,
|
||||
handled: counts[1] ?? 0,
|
||||
requests: counts[2] ?? 0,
|
||||
reading: rw[0] ?? 0,
|
||||
writing: rw[1] ?? 0,
|
||||
waiting: rw[2] ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url: string, timeoutMs = 5000): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, { signal: controller.signal, cache: "no-store" });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// Sample tile coordinates for cache testing (Romania, z8)
|
||||
const SAMPLE_TILES = [
|
||||
{ z: 8, x: 143, y: 91, source: "gis_uats_z8" },
|
||||
{ z: 17, x: 73640, y: 47720, source: "gis_terenuri" },
|
||||
];
|
||||
|
||||
export async function GET() {
|
||||
const result: Record<string, unknown> = {
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// 1. Nginx status
|
||||
try {
|
||||
const res = await fetchWithTimeout(`${TILE_CACHE_INTERNAL}/status`);
|
||||
if (res.ok) {
|
||||
result.nginx = parseNginxStatus(await res.text());
|
||||
} else {
|
||||
result.nginx = { error: `HTTP ${res.status}` };
|
||||
}
|
||||
} catch {
|
||||
result.nginx = { error: "tile-cache unreachable" };
|
||||
}
|
||||
|
||||
// 2. Martin catalog
|
||||
try {
|
||||
const res = await fetchWithTimeout(`${MARTIN_INTERNAL}/catalog`);
|
||||
if (res.ok) {
|
||||
const catalog = await res.json() as { tiles?: Record<string, unknown> };
|
||||
const sources = Object.keys(catalog.tiles ?? {});
|
||||
result.martin = { status: "ok", sources, sourceCount: sources.length };
|
||||
} else {
|
||||
result.martin = { error: `HTTP ${res.status}` };
|
||||
}
|
||||
} catch {
|
||||
result.martin = { error: "martin unreachable" };
|
||||
}
|
||||
|
||||
// 3. PMTiles info
|
||||
if (PMTILES_URL) {
|
||||
try {
|
||||
const res = await fetchWithTimeout(PMTILES_FETCH_URL, 3000);
|
||||
result.pmtiles = {
|
||||
url: PMTILES_URL,
|
||||
status: res.ok ? "ok" : `HTTP ${res.status}`,
|
||||
size: res.headers.get("content-length")
|
||||
? `${(parseInt(res.headers.get("content-length") ?? "0", 10) / 1024 / 1024).toFixed(1)} MB`
|
||||
: "unknown",
|
||||
lastModified: res.headers.get("last-modified") ?? "unknown",
|
||||
};
|
||||
} catch {
|
||||
result.pmtiles = { url: PMTILES_URL, error: "unreachable" };
|
||||
}
|
||||
} else {
|
||||
result.pmtiles = { status: "not configured" };
|
||||
}
|
||||
|
||||
// 4. Cache test — request sample tiles and check X-Cache-Status
|
||||
const cacheTests: Record<string, string>[] = [];
|
||||
for (const tile of SAMPLE_TILES) {
|
||||
try {
|
||||
const url = `${TILE_CACHE_INTERNAL}/${tile.source}/${tile.z}/${tile.x}/${tile.y}`;
|
||||
const res = await fetchWithTimeout(url, 10000);
|
||||
cacheTests.push({
|
||||
tile: `${tile.source}/${tile.z}/${tile.x}/${tile.y}`,
|
||||
status: `${res.status}`,
|
||||
cache: res.headers.get("x-cache-status") ?? "unknown",
|
||||
});
|
||||
} catch {
|
||||
cacheTests.push({
|
||||
tile: `${tile.source}/${tile.z}/${tile.x}/${tile.y}`,
|
||||
status: "error",
|
||||
cache: "unreachable",
|
||||
});
|
||||
}
|
||||
}
|
||||
result.cacheTests = cacheTests;
|
||||
|
||||
// 5. Config summary
|
||||
result.config = {
|
||||
martinUrl: process.env.NEXT_PUBLIC_MARTIN_URL ?? "(not set)",
|
||||
pmtilesUrl: PMTILES_URL || "(not set)",
|
||||
n8nWebhook: N8N_WEBHOOK_URL ? "configured" : "not set",
|
||||
};
|
||||
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
|
||||
async function getPmtilesInfo(): Promise<{ size: string; lastModified: string } | null> {
|
||||
if (!PMTILES_URL) return null;
|
||||
try {
|
||||
const res = await fetchWithTimeout(PMTILES_FETCH_URL, 3000);
|
||||
return {
|
||||
size: res.headers.get("content-length")
|
||||
? `${(parseInt(res.headers.get("content-length") ?? "0", 10) / 1024 / 1024).toFixed(1)} MB`
|
||||
: "unknown",
|
||||
lastModified: res.headers.get("last-modified") ?? "unknown",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json() as { action?: string };
|
||||
const action = body.action;
|
||||
|
||||
if (action === "rebuild") {
|
||||
// Get current PMTiles state before rebuild
|
||||
const before = await getPmtilesInfo();
|
||||
const result = await firePmtilesRebuild("manual-rebuild");
|
||||
if (!result.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: "Webhook PMTiles indisponibil — verifica N8N_WEBHOOK_URL si serviciul pmtiles-webhook" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
action: "rebuild",
|
||||
alreadyRunning: result.alreadyRunning ?? false,
|
||||
previousPmtiles: before,
|
||||
message: result.alreadyRunning
|
||||
? "Rebuild PMTiles deja in curs. Urmareste PMTiles last-modified."
|
||||
: "Rebuild PMTiles pornit. Dureaza ~8 min. Urmareste PMTiles last-modified.",
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "check-rebuild") {
|
||||
// Check if PMTiles was updated since a given timestamp
|
||||
const previousLastModified = (body as { previousLastModified?: string }).previousLastModified;
|
||||
const current = await getPmtilesInfo();
|
||||
const changed = !!current && !!previousLastModified && current.lastModified !== previousLastModified;
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
action: "check-rebuild",
|
||||
current,
|
||||
changed,
|
||||
message: changed
|
||||
? `Rebuild finalizat! PMTiles actualizat: ${current?.size}, ${current?.lastModified}`
|
||||
: "Rebuild in curs...",
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "warm-cache") {
|
||||
const sources = ["gis_terenuri", "gis_cladiri"];
|
||||
let total = 0;
|
||||
let hits = 0;
|
||||
let misses = 0;
|
||||
let errors = 0;
|
||||
const promises: Promise<void>[] = [];
|
||||
for (const source of sources) {
|
||||
for (let x = 9200; x <= 9210; x++) {
|
||||
for (let y = 5960; y <= 5970; y++) {
|
||||
total++;
|
||||
promises.push(
|
||||
fetchWithTimeout(`${TILE_CACHE_INTERNAL}/${source}/14/${x}/${y}`, 30000)
|
||||
.then((res) => {
|
||||
const cache = res.headers.get("x-cache-status") ?? "";
|
||||
if (cache === "HIT") hits++;
|
||||
else misses++;
|
||||
})
|
||||
.catch(() => { errors++; }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(promises);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
action: "warm-cache",
|
||||
total,
|
||||
hits,
|
||||
misses,
|
||||
errors,
|
||||
message: `${total} tile-uri procesate: ${hits} HIT, ${misses} MISS (nou incarcate), ${errors} erori`,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* POST /api/geoportal/optimize-tiles
|
||||
*
|
||||
* Slims down gis_features/terenuri/cladiri/administrativ views
|
||||
* by removing heavy JSON columns (attributes, enrichment, timestamps).
|
||||
* Makes Martin vector tiles much smaller and faster.
|
||||
*
|
||||
* Safe to re-run (CREATE OR REPLACE VIEW).
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const STEPS = [
|
||||
// Drop dependent views first (they reference gis_features)
|
||||
{ name: "Drop gis_documentatii", sql: `DROP VIEW IF EXISTS gis_documentatii CASCADE` },
|
||||
{ name: "Drop gis_administrativ", sql: `DROP VIEW IF EXISTS gis_administrativ CASCADE` },
|
||||
{ name: "Drop gis_cladiri", sql: `DROP VIEW IF EXISTS gis_cladiri CASCADE` },
|
||||
{ name: "Drop gis_terenuri", sql: `DROP VIEW IF EXISTS gis_terenuri CASCADE` },
|
||||
{ name: "Drop gis_features", sql: `DROP VIEW IF EXISTS gis_features CASCADE` },
|
||||
{
|
||||
name: "gis_features (slim)",
|
||||
sql: `CREATE OR REPLACE VIEW gis_features AS
|
||||
SELECT id, "layerId" AS layer_id, siruta, "objectId" AS object_id,
|
||||
"cadastralRef" AS cadastral_ref, "areaValue" AS area_value,
|
||||
"isActive" AS is_active, geom
|
||||
FROM "GisFeature" WHERE geom IS NOT NULL`,
|
||||
},
|
||||
{
|
||||
name: "gis_terenuri",
|
||||
sql: `CREATE OR REPLACE VIEW gis_terenuri AS
|
||||
SELECT * FROM gis_features
|
||||
WHERE layer_id LIKE 'TERENURI%' OR layer_id LIKE 'CADGEN_LAND%'`,
|
||||
},
|
||||
{
|
||||
name: "gis_cladiri",
|
||||
sql: `CREATE OR REPLACE VIEW gis_cladiri AS
|
||||
SELECT * FROM gis_features
|
||||
WHERE layer_id LIKE 'CLADIRI%' OR layer_id LIKE 'CADGEN_BUILDING%'`,
|
||||
},
|
||||
{
|
||||
name: "gis_administrativ",
|
||||
sql: `CREATE OR REPLACE VIEW gis_administrativ AS
|
||||
SELECT * FROM gis_features
|
||||
WHERE layer_id LIKE 'LIMITE%' OR layer_id LIKE 'SPECIAL_AREAS%'`,
|
||||
},
|
||||
{
|
||||
name: "gis_documentatii",
|
||||
sql: `CREATE OR REPLACE VIEW gis_documentatii AS
|
||||
SELECT * FROM gis_features
|
||||
WHERE layer_id LIKE 'EXPERTIZA%' OR layer_id LIKE 'ZONE_INTERES%' OR layer_id LIKE 'RECEPTII%'`,
|
||||
},
|
||||
];
|
||||
|
||||
export async function POST() {
|
||||
const results: string[] = [];
|
||||
try {
|
||||
for (const step of STEPS) {
|
||||
await prisma.$executeRawUnsafe(step.sql);
|
||||
results.push(`${step.name} OK`);
|
||||
}
|
||||
return NextResponse.json({ status: "ok", results });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare";
|
||||
return NextResponse.json({ status: "error", results, error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Check if views are slim (no 'attributes' column)
|
||||
const cols = await prisma.$queryRaw`
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'gis_features' AND table_schema = 'public'
|
||||
ORDER BY ordinal_position
|
||||
` as Array<{ column_name: string }>;
|
||||
const hasAttributes = cols.some((c) => c.column_name === "attributes");
|
||||
return NextResponse.json({
|
||||
optimized: !hasAttributes,
|
||||
columns: cols.map((c) => c.column_name),
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ optimized: false, columns: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* POST /api/geoportal/optimize-views
|
||||
*
|
||||
* Replaces on-the-fly ST_Simplify views with materialized columns.
|
||||
* This eliminates CPU-heavy geometry simplification on every Martin tile request.
|
||||
*
|
||||
* What it does:
|
||||
* 1. Adds geom_z0/z5/z8 columns to GisUat table (pre-simplified geometry)
|
||||
* 2. Backfills them from the original geom column
|
||||
* 3. Creates spatial indexes on each
|
||||
* 4. Replaces views to use pre-computed columns instead of on-the-fly simplification
|
||||
*
|
||||
* Safe to re-run (idempotent). Original geom column is NEVER modified.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const STEPS = [
|
||||
// 0. Drop existing views that reference geom (they block ALTER TABLE)
|
||||
{ name: "Drop gis_uats_z0 view", sql: `DROP VIEW IF EXISTS gis_uats_z0 CASCADE` },
|
||||
{ name: "Drop gis_uats_z5 view", sql: `DROP VIEW IF EXISTS gis_uats_z5 CASCADE` },
|
||||
{ name: "Drop gis_uats_z8 view", sql: `DROP VIEW IF EXISTS gis_uats_z8 CASCADE` },
|
||||
{ name: "Drop gis_uats_z12 view", sql: `DROP VIEW IF EXISTS gis_uats_z12 CASCADE` },
|
||||
{ name: "Drop gis_uats view", sql: `DROP VIEW IF EXISTS gis_uats CASCADE` },
|
||||
|
||||
// 1. Add pre-simplified geometry columns (plain geometry, no typed constraint)
|
||||
{
|
||||
name: "Add geom_z0 column (2000m)",
|
||||
sql: `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='GisUat' AND column_name='geom_z0') THEN ALTER TABLE "GisUat" ADD COLUMN geom_z0 geometry; END IF; END $$`,
|
||||
},
|
||||
{
|
||||
name: "Add geom_z5 column (500m)",
|
||||
sql: `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='GisUat' AND column_name='geom_z5') THEN ALTER TABLE "GisUat" ADD COLUMN geom_z5 geometry; END IF; END $$`,
|
||||
},
|
||||
{
|
||||
name: "Add geom_z8 column (50m)",
|
||||
sql: `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='GisUat' AND column_name='geom_z8') THEN ALTER TABLE "GisUat" ADD COLUMN geom_z8 geometry; END IF; END $$`,
|
||||
},
|
||||
|
||||
// 2. Backfill with pre-computed simplified geometries
|
||||
{
|
||||
name: "Backfill geom_z0 (2000m simplification)",
|
||||
sql: `UPDATE "GisUat" SET geom_z0 = ST_SimplifyPreserveTopology(geom, 2000) WHERE geom IS NOT NULL AND geom_z0 IS NULL`,
|
||||
},
|
||||
{
|
||||
name: "Backfill geom_z5 (500m simplification)",
|
||||
sql: `UPDATE "GisUat" SET geom_z5 = ST_SimplifyPreserveTopology(geom, 500) WHERE geom IS NOT NULL AND geom_z5 IS NULL`,
|
||||
},
|
||||
{
|
||||
name: "Backfill geom_z8 (50m simplification)",
|
||||
sql: `UPDATE "GisUat" SET geom_z8 = ST_SimplifyPreserveTopology(geom, 50) WHERE geom IS NOT NULL AND geom_z8 IS NULL`,
|
||||
},
|
||||
|
||||
// 3. Spatial indexes
|
||||
{
|
||||
name: "Index geom_z0",
|
||||
sql: `CREATE INDEX IF NOT EXISTS gis_uat_geom_z0_idx ON "GisUat" USING GIST (geom_z0)`,
|
||||
},
|
||||
{
|
||||
name: "Index geom_z5",
|
||||
sql: `CREATE INDEX IF NOT EXISTS gis_uat_geom_z5_idx ON "GisUat" USING GIST (geom_z5)`,
|
||||
},
|
||||
{
|
||||
name: "Index geom_z8",
|
||||
sql: `CREATE INDEX IF NOT EXISTS gis_uat_geom_z8_idx ON "GisUat" USING GIST (geom_z8)`,
|
||||
},
|
||||
|
||||
// 4. Replace views to use pre-computed columns (zero CPU on read)
|
||||
{
|
||||
name: "Replace gis_uats_z0 view",
|
||||
sql: `CREATE OR REPLACE VIEW gis_uats_z0 AS SELECT siruta, name, geom_z0 AS geom FROM "GisUat" WHERE geom_z0 IS NOT NULL`,
|
||||
},
|
||||
{
|
||||
name: "Replace gis_uats_z5 view",
|
||||
sql: `CREATE OR REPLACE VIEW gis_uats_z5 AS SELECT siruta, name, geom_z5 AS geom FROM "GisUat" WHERE geom_z5 IS NOT NULL`,
|
||||
},
|
||||
{
|
||||
name: "Replace gis_uats_z8 view",
|
||||
sql: `CREATE OR REPLACE VIEW gis_uats_z8 AS SELECT siruta, name, county, geom_z8 AS geom FROM "GisUat" WHERE geom_z8 IS NOT NULL`,
|
||||
},
|
||||
{
|
||||
name: "Replace gis_uats_z12 view (original geom)",
|
||||
sql: `CREATE OR REPLACE VIEW gis_uats_z12 AS SELECT siruta, name, county, geom FROM "GisUat" WHERE geom IS NOT NULL`,
|
||||
},
|
||||
|
||||
// 5. Restore legacy gis_uats view for QGIS compatibility
|
||||
{
|
||||
name: "Restore gis_uats view",
|
||||
sql: `CREATE OR REPLACE VIEW gis_uats AS SELECT siruta, name, county, geom_z8 AS geom FROM "GisUat" WHERE geom_z8 IS NOT NULL`,
|
||||
},
|
||||
|
||||
// 6. Slim down gis_terenuri/cladiri views (drop huge attributes/enrichment JSON columns)
|
||||
{
|
||||
name: "Optimize gis_features view (slim columns)",
|
||||
sql: `CREATE OR REPLACE VIEW gis_features AS SELECT id, "layerId" AS layer_id, siruta, "objectId" AS object_id, "cadastralRef" AS cadastral_ref, "areaValue" AS area_value, "isActive" AS is_active, geom FROM "GisFeature" WHERE geom IS NOT NULL`,
|
||||
},
|
||||
{
|
||||
name: "Optimize gis_terenuri view",
|
||||
sql: `CREATE OR REPLACE VIEW gis_terenuri AS SELECT * FROM gis_features WHERE layer_id LIKE 'TERENURI%' OR layer_id LIKE 'CADGEN_LAND%'`,
|
||||
},
|
||||
{
|
||||
name: "Optimize gis_cladiri view",
|
||||
sql: `CREATE OR REPLACE VIEW gis_cladiri AS SELECT * FROM gis_features WHERE layer_id LIKE 'CLADIRI%' OR layer_id LIKE 'CADGEN_BUILDING%'`,
|
||||
},
|
||||
{
|
||||
name: "Optimize gis_administrativ view",
|
||||
sql: `CREATE OR REPLACE VIEW gis_administrativ AS SELECT * FROM gis_features WHERE layer_id LIKE 'LIMITE%' OR layer_id LIKE 'SPECIAL_AREAS%'`,
|
||||
},
|
||||
|
||||
// 7. Update trigger to also compute simplified geoms on INSERT/UPDATE
|
||||
{
|
||||
name: "Update trigger to pre-compute simplified geoms",
|
||||
sql: `CREATE OR REPLACE FUNCTION gis_uat_sync_geom() RETURNS TRIGGER AS $$ BEGIN IF NEW.geometry IS NOT NULL THEN BEGIN NEW.geom := gis_uat_esri_to_geom(NEW.geometry::jsonb); IF NEW.geom IS NOT NULL THEN NEW.geom_z0 := ST_SimplifyPreserveTopology(NEW.geom, 2000); NEW.geom_z5 := ST_SimplifyPreserveTopology(NEW.geom, 500); NEW.geom_z8 := ST_SimplifyPreserveTopology(NEW.geom, 50); END IF; EXCEPTION WHEN OTHERS THEN NEW.geom := NULL; NEW.geom_z0 := NULL; NEW.geom_z5 := NULL; NEW.geom_z8 := NULL; END; ELSE NEW.geom := NULL; NEW.geom_z0 := NULL; NEW.geom_z5 := NULL; NEW.geom_z8 := NULL; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql`,
|
||||
},
|
||||
];
|
||||
|
||||
export async function POST() {
|
||||
const results: string[] = [];
|
||||
try {
|
||||
for (const step of STEPS) {
|
||||
await prisma.$executeRawUnsafe(step.sql);
|
||||
results.push(`${step.name} OK`);
|
||||
}
|
||||
return NextResponse.json({ status: "ok", results });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare";
|
||||
return NextResponse.json({ status: "error", results, error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/** GET — check optimization status */
|
||||
export async function GET() {
|
||||
try {
|
||||
const cols = await prisma.$queryRaw`
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'GisUat' AND column_name LIKE 'geom_z%'
|
||||
` as Array<{ column_name: string }>;
|
||||
|
||||
const optimized = cols.length >= 3;
|
||||
return NextResponse.json({ optimized, columns: cols.map((c) => c.column_name) });
|
||||
} catch {
|
||||
return NextResponse.json({ optimized: false, columns: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* GET /api/geoportal/search?q=...&type=...&limit=...
|
||||
*
|
||||
* Searches parcels (by cadastral ref, owner) and UATs (by name).
|
||||
* Returns centroids in EPSG:4326 (WGS84) for map flyTo.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type SearchResultItem = {
|
||||
id: string;
|
||||
type: "parcel" | "uat" | "building";
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
coordinates?: [number, number];
|
||||
};
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const q = url.searchParams.get("q")?.trim() ?? "";
|
||||
const typeFilter = url.searchParams.get("type") ?? "";
|
||||
const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "20", 10), 50);
|
||||
|
||||
if (q.length < 2) {
|
||||
return NextResponse.json({ results: [] });
|
||||
}
|
||||
|
||||
const results: SearchResultItem[] = [];
|
||||
const pattern = `%${q}%`;
|
||||
|
||||
// Search UATs by name
|
||||
if (!typeFilter || typeFilter === "uat") {
|
||||
const uats = await prisma.$queryRaw`
|
||||
SELECT
|
||||
siruta,
|
||||
name,
|
||||
county,
|
||||
ST_X(ST_Centroid(ST_Transform(geom, 4326))) as lng,
|
||||
ST_Y(ST_Centroid(ST_Transform(geom, 4326))) as lat
|
||||
FROM "GisUat"
|
||||
WHERE geom IS NOT NULL
|
||||
AND (name ILIKE ${pattern} OR county ILIKE ${pattern})
|
||||
ORDER BY name
|
||||
LIMIT ${limit}
|
||||
` as Array<{ siruta: string; name: string; county: string | null; lng: number; lat: number }>;
|
||||
|
||||
for (const u of uats) {
|
||||
results.push({
|
||||
id: `uat-${u.siruta}`,
|
||||
type: "uat",
|
||||
label: u.name,
|
||||
sublabel: u.county ? `Jud. ${u.county}` : undefined,
|
||||
coordinates: u.lng && u.lat ? [u.lng, u.lat] : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Search parcels by cadastral ref or enrichment data
|
||||
if (!typeFilter || typeFilter === "parcel") {
|
||||
const isNumericish = /^\d/.test(q);
|
||||
|
||||
if (isNumericish) {
|
||||
// Search by cadastral reference
|
||||
const parcels = await prisma.$queryRaw`
|
||||
SELECT
|
||||
f.id,
|
||||
f."cadastralRef",
|
||||
f."areaValue",
|
||||
f.siruta,
|
||||
f.enrichment,
|
||||
u.name as uat_name,
|
||||
ST_X(ST_Centroid(ST_Transform(f.geom, 4326))) as lng,
|
||||
ST_Y(ST_Centroid(ST_Transform(f.geom, 4326))) as lat
|
||||
FROM "GisFeature" f
|
||||
LEFT JOIN "GisUat" u ON u.siruta = f.siruta
|
||||
WHERE f.geom IS NOT NULL
|
||||
AND f."layerId" LIKE 'TERENURI%'
|
||||
AND (f."cadastralRef" ILIKE ${pattern}
|
||||
OR f.enrichment::text ILIKE ${`%"NR_CAD":"${q}%`})
|
||||
ORDER BY f."cadastralRef"
|
||||
LIMIT ${limit}
|
||||
` as Array<{
|
||||
id: string;
|
||||
cadastralRef: string | null;
|
||||
areaValue: number | null;
|
||||
siruta: string;
|
||||
uat_name: string | null;
|
||||
enrichment: Record<string, unknown> | null;
|
||||
lng: number;
|
||||
lat: number;
|
||||
}>;
|
||||
|
||||
for (const p of parcels) {
|
||||
const nrCad = (p.enrichment?.NR_CAD as string) ?? p.cadastralRef ?? "?";
|
||||
const area = p.areaValue ? `${Math.round(p.areaValue)} mp` : "";
|
||||
const uatLabel = p.uat_name ?? `SIRUTA ${p.siruta}`;
|
||||
results.push({
|
||||
id: `parcel-${p.id}`,
|
||||
type: "parcel",
|
||||
label: `Parcela ${nrCad} — ${uatLabel}`,
|
||||
sublabel: [area, p.uat_name ? `SIRUTA ${p.siruta}` : ""].filter(Boolean).join(" | "),
|
||||
coordinates: p.lng && p.lat ? [p.lng, p.lat] : undefined,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Search by owner name in enrichment JSON
|
||||
const parcels = await prisma.$queryRaw`
|
||||
SELECT
|
||||
f.id,
|
||||
f."cadastralRef",
|
||||
f."areaValue",
|
||||
f.siruta,
|
||||
f.enrichment,
|
||||
u.name as uat_name,
|
||||
ST_X(ST_Centroid(ST_Transform(f.geom, 4326))) as lng,
|
||||
ST_Y(ST_Centroid(ST_Transform(f.geom, 4326))) as lat
|
||||
FROM "GisFeature" f
|
||||
LEFT JOIN "GisUat" u ON u.siruta = f.siruta
|
||||
WHERE f.geom IS NOT NULL
|
||||
AND f."layerId" LIKE 'TERENURI%'
|
||||
AND f.enrichment IS NOT NULL
|
||||
AND f.enrichment::text ILIKE ${pattern}
|
||||
ORDER BY f."cadastralRef"
|
||||
LIMIT ${limit}
|
||||
` as Array<{
|
||||
id: string;
|
||||
cadastralRef: string | null;
|
||||
areaValue: number | null;
|
||||
siruta: string;
|
||||
uat_name: string | null;
|
||||
enrichment: Record<string, unknown> | null;
|
||||
lng: number;
|
||||
lat: number;
|
||||
}>;
|
||||
|
||||
for (const p of parcels) {
|
||||
const nrCad = (p.enrichment?.NR_CAD as string) ?? p.cadastralRef ?? "?";
|
||||
const owner = (p.enrichment?.PROPRIETARI as string) ?? "";
|
||||
const uatLabel = p.uat_name ?? `SIRUTA ${p.siruta}`;
|
||||
const ownerShort = owner.length > 60 ? owner.slice(0, 60) + "..." : owner;
|
||||
results.push({
|
||||
id: `parcel-${p.id}`,
|
||||
type: "parcel",
|
||||
label: `Parcela ${nrCad} — ${uatLabel}`,
|
||||
sublabel: [ownerShort, p.uat_name ? `SIRUTA ${p.siruta}` : ""].filter(Boolean).join(" | "),
|
||||
coordinates: p.lng && p.lat ? [p.lng, p.lat] : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ results: results.slice(0, limit) });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare la cautare";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* GET /api/geoportal/setup-enrichment-views — check if views exist
|
||||
* POST /api/geoportal/setup-enrichment-views — create enrichment status views
|
||||
*
|
||||
* Creates gis_terenuri_status and gis_cladiri_status views that include
|
||||
* enrichment metadata (has_enrichment, has_building, build_legal).
|
||||
* Martin serves these as vector tile sources, MapLibre uses them for
|
||||
* data-driven styling in the ParcelSync Harta tab.
|
||||
*
|
||||
* IMPORTANT: Does NOT modify existing gis_terenuri/gis_cladiri views
|
||||
* used by the Geoportal module.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const VIEWS = [
|
||||
{
|
||||
name: "gis_terenuri_status",
|
||||
sql: `CREATE OR REPLACE VIEW gis_terenuri_status AS
|
||||
SELECT
|
||||
f.id,
|
||||
f."layerId" AS layer_id,
|
||||
f.siruta,
|
||||
f."objectId" AS object_id,
|
||||
f."cadastralRef" AS cadastral_ref,
|
||||
f."areaValue" AS area_value,
|
||||
f."isActive" AS is_active,
|
||||
CASE WHEN f.enrichment IS NOT NULL AND f."enrichedAt" IS NOT NULL THEN 1 ELSE 0 END AS has_enrichment,
|
||||
COALESCE((f.enrichment->>'HAS_BUILDING')::int, 0) AS has_building,
|
||||
COALESCE((f.enrichment->>'BUILD_LEGAL')::int, 0) AS build_legal,
|
||||
f.geom
|
||||
FROM "GisFeature" f
|
||||
WHERE f.geom IS NOT NULL
|
||||
AND (f."layerId" LIKE 'TERENURI%' OR f."layerId" LIKE 'CADGEN_LAND%')`,
|
||||
},
|
||||
{
|
||||
name: "gis_cladiri_status",
|
||||
sql: `CREATE OR REPLACE VIEW gis_cladiri_status AS
|
||||
SELECT
|
||||
f.id,
|
||||
f."layerId" AS layer_id,
|
||||
f.siruta,
|
||||
f."objectId" AS object_id,
|
||||
f."cadastralRef" AS cadastral_ref,
|
||||
f."areaValue" AS area_value,
|
||||
f."isActive" AS is_active,
|
||||
COALESCE(
|
||||
(SELECT (p.enrichment->>'BUILD_LEGAL')::int
|
||||
FROM "GisFeature" p
|
||||
WHERE p.siruta = f.siruta
|
||||
AND p."cadastralRef" = f."cadastralRef"
|
||||
AND (p."layerId" LIKE 'TERENURI%' OR p."layerId" LIKE 'CADGEN_LAND%')
|
||||
AND p.enrichment IS NOT NULL
|
||||
LIMIT 1),
|
||||
-1
|
||||
) AS build_legal,
|
||||
f.geom
|
||||
FROM "GisFeature" f
|
||||
WHERE f.geom IS NOT NULL
|
||||
AND (f."layerId" LIKE 'CLADIRI%' OR f."layerId" LIKE 'CADGEN_BUILDING%')`,
|
||||
},
|
||||
];
|
||||
|
||||
/** GET — check if enrichment views exist */
|
||||
export async function GET() {
|
||||
try {
|
||||
const existing = await prisma.$queryRaw`
|
||||
SELECT viewname FROM pg_views
|
||||
WHERE schemaname = 'public' AND (viewname = 'gis_terenuri_status' OR viewname = 'gis_cladiri_status')
|
||||
` as Array<{ viewname: string }>;
|
||||
|
||||
const existingNames = new Set(existing.map((r) => r.viewname));
|
||||
const missing = VIEWS.filter((v) => !existingNames.has(v.name)).map((v) => v.name);
|
||||
|
||||
return NextResponse.json({ ready: missing.length === 0, missing });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare";
|
||||
return NextResponse.json({ ready: false, missing: VIEWS.map((v) => v.name), error: msg });
|
||||
}
|
||||
}
|
||||
|
||||
/** POST — create enrichment views (idempotent, drops first if structure changed) */
|
||||
export async function POST() {
|
||||
const results: string[] = [];
|
||||
try {
|
||||
for (const v of VIEWS) {
|
||||
// DROP first — CREATE OR REPLACE fails when columns change
|
||||
await prisma.$executeRawUnsafe(`DROP VIEW IF EXISTS ${v.name} CASCADE`);
|
||||
await prisma.$executeRawUnsafe(v.sql);
|
||||
results.push(`${v.name} OK`);
|
||||
}
|
||||
return NextResponse.json({ status: "ok", results });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare";
|
||||
return NextResponse.json({ status: "error", results, error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* GET /api/geoportal/setup-views — check if views exist
|
||||
* POST /api/geoportal/setup-views — create views (idempotent)
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const VIEWS = [
|
||||
{
|
||||
name: "gis_uats_z0",
|
||||
sql: `CREATE OR REPLACE VIEW gis_uats_z0 AS SELECT siruta, name, ST_SimplifyPreserveTopology(geom, 2000) AS geom FROM "GisUat" WHERE geom IS NOT NULL`,
|
||||
},
|
||||
{
|
||||
name: "gis_uats_z5",
|
||||
sql: `CREATE OR REPLACE VIEW gis_uats_z5 AS SELECT siruta, name, ST_SimplifyPreserveTopology(geom, 500) AS geom FROM "GisUat" WHERE geom IS NOT NULL`,
|
||||
},
|
||||
{
|
||||
name: "gis_uats_z8",
|
||||
sql: `CREATE OR REPLACE VIEW gis_uats_z8 AS SELECT siruta, name, county, ST_SimplifyPreserveTopology(geom, 50) AS geom FROM "GisUat" WHERE geom IS NOT NULL`,
|
||||
},
|
||||
{
|
||||
name: "gis_uats_z12",
|
||||
sql: `CREATE OR REPLACE VIEW gis_uats_z12 AS SELECT siruta, name, county, geom FROM "GisUat" WHERE geom IS NOT NULL`,
|
||||
},
|
||||
];
|
||||
|
||||
/** GET — returns { ready: boolean, missing: string[] } */
|
||||
export async function GET() {
|
||||
try {
|
||||
const existing = await prisma.$queryRaw`
|
||||
SELECT viewname FROM pg_views
|
||||
WHERE schemaname = 'public' AND viewname LIKE 'gis_uats_z%'
|
||||
` as Array<{ viewname: string }>;
|
||||
|
||||
const existingNames = new Set(existing.map((r) => r.viewname));
|
||||
const missing = VIEWS.filter((v) => !existingNames.has(v.name)).map((v) => v.name);
|
||||
|
||||
return NextResponse.json({ ready: missing.length === 0, missing });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare";
|
||||
return NextResponse.json({ ready: false, missing: VIEWS.map((v) => v.name), error: msg });
|
||||
}
|
||||
}
|
||||
|
||||
/** POST — creates all views (idempotent) */
|
||||
export async function POST() {
|
||||
const results: string[] = [];
|
||||
try {
|
||||
for (const v of VIEWS) {
|
||||
await prisma.$executeRawUnsafe(v.sql);
|
||||
results.push(`${v.name} OK`);
|
||||
}
|
||||
return NextResponse.json({ status: "ok", results });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare";
|
||||
return NextResponse.json({ status: "error", results, error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* GET /api/geoportal/uat-bounds?siruta=57582
|
||||
*
|
||||
* Returns WGS84 bounding box for a UAT from PostGIS geometry.
|
||||
* Used by ParcelSync Harta tab to zoom to selected UAT.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const siruta = req.nextUrl.searchParams.get("siruta");
|
||||
if (!siruta) {
|
||||
return NextResponse.json({ error: "siruta required" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await prisma.$queryRaw`
|
||||
SELECT
|
||||
ST_XMin(ST_Transform(geom, 4326)) AS min_lng,
|
||||
ST_YMin(ST_Transform(geom, 4326)) AS min_lat,
|
||||
ST_XMax(ST_Transform(geom, 4326)) AS max_lng,
|
||||
ST_YMax(ST_Transform(geom, 4326)) AS max_lat
|
||||
FROM "GisUat"
|
||||
WHERE siruta = ${siruta} AND geom IS NOT NULL
|
||||
LIMIT 1
|
||||
` as Array<{
|
||||
min_lng: number;
|
||||
min_lat: number;
|
||||
max_lng: number;
|
||||
max_lat: number;
|
||||
}>;
|
||||
|
||||
const first = rows[0];
|
||||
if (!first) {
|
||||
return NextResponse.json({ error: "UAT not found or no geometry" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
siruta,
|
||||
bounds: [
|
||||
[first.min_lng, first.min_lat],
|
||||
[first.max_lng, first.max_lat],
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* GET /api/notifications/app — list recent + unread count
|
||||
* PATCH /api/notifications/app — mark read / mark all read
|
||||
*
|
||||
* Body for PATCH:
|
||||
* { action: "mark-read", id: string }
|
||||
* { action: "mark-all-read" }
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getAppNotifications,
|
||||
getUnreadCount,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
} from "@/core/notifications/app-notifications";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "30", 10), 100);
|
||||
|
||||
const [notifications, unreadCount] = await Promise.all([
|
||||
getAppNotifications(limit),
|
||||
getUnreadCount(),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ notifications, unreadCount });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare notificari";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(req: Request) {
|
||||
try {
|
||||
const body = (await req.json()) as { action: string; id?: string };
|
||||
|
||||
if (body.action === "mark-read" && body.id) {
|
||||
await markAsRead(body.id);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
if (body.action === "mark-all-read") {
|
||||
await markAllAsRead();
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Actiune necunoscuta" }, { status: 400 });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare notificari";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { runDigest } from "@/core/notifications";
|
||||
import { sendTestDigest } from "@/core/notifications/notification-service";
|
||||
|
||||
/**
|
||||
* POST /api/notifications/digest
|
||||
*
|
||||
* Server-to-server endpoint called by N8N cron.
|
||||
* Auth via Authorization: Bearer <NOTIFICATION_CRON_SECRET>
|
||||
*
|
||||
* Query params:
|
||||
* ?test=true — send a test email with sample data to all subscribers
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const secret = process.env.NOTIFICATION_CRON_SECRET;
|
||||
|
||||
if (!secret) {
|
||||
return NextResponse.json(
|
||||
{ error: "NOTIFICATION_CRON_SECRET not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
const token = authHeader?.replace("Bearer ", "");
|
||||
|
||||
if (token !== secret) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const isTest = url.searchParams.get("test") === "true";
|
||||
|
||||
const result = isTest ? await sendTestDigest() : await runDigest();
|
||||
|
||||
return NextResponse.json(result, {
|
||||
status: result.success ? 200 : 500,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getAuthSession } from "@/core/auth";
|
||||
import type { CompanyId } from "@/core/auth/types";
|
||||
import {
|
||||
getPreference,
|
||||
savePreference,
|
||||
defaultPreference,
|
||||
} from "@/core/notifications";
|
||||
import type { NotificationType, NotificationPreference } from "@/core/notifications";
|
||||
|
||||
const VALID_TYPES: NotificationType[] = [
|
||||
"deadline-urgent",
|
||||
"deadline-overdue",
|
||||
"document-expiry",
|
||||
"status-change",
|
||||
];
|
||||
|
||||
type SessionUser = {
|
||||
id?: string;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
company?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/notifications/preferences
|
||||
*
|
||||
* Returns the current user's notification preferences.
|
||||
* Creates defaults (all enabled) if none exist.
|
||||
*/
|
||||
export async function GET() {
|
||||
const session = await getAuthSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const u = session.user as SessionUser;
|
||||
const id = u.id ?? "unknown";
|
||||
const email = u.email ?? "";
|
||||
const name = u.name ?? "";
|
||||
const company = (u.company ?? "beletage") as CompanyId;
|
||||
|
||||
let pref = await getPreference(id);
|
||||
|
||||
if (!pref) {
|
||||
pref = defaultPreference(id, email, name, company);
|
||||
await savePreference(pref);
|
||||
}
|
||||
|
||||
return NextResponse.json(pref);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/notifications/preferences
|
||||
*
|
||||
* Update the current user's notification preferences.
|
||||
* Body: { enabledTypes?: NotificationType[], globalOptOut?: boolean }
|
||||
*/
|
||||
export async function PUT(request: Request) {
|
||||
const session = await getAuthSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const u = session.user as SessionUser;
|
||||
const id = u.id ?? "unknown";
|
||||
const email = u.email ?? "";
|
||||
const name = u.name ?? "";
|
||||
const company = (u.company ?? "beletage") as CompanyId;
|
||||
|
||||
const body = (await request.json()) as Partial<
|
||||
Pick<NotificationPreference, "enabledTypes" | "globalOptOut">
|
||||
>;
|
||||
|
||||
// Validate types
|
||||
if (body.enabledTypes) {
|
||||
const invalid = body.enabledTypes.filter(
|
||||
(t) => !VALID_TYPES.includes(t),
|
||||
);
|
||||
if (invalid.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `Tipuri invalide: ${invalid.join(", ")}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Load existing or create default
|
||||
let pref = await getPreference(id);
|
||||
if (!pref) {
|
||||
pref = defaultPreference(id, email, name, company);
|
||||
}
|
||||
|
||||
// Update fields
|
||||
if (body.enabledTypes !== undefined) {
|
||||
pref.enabledTypes = body.enabledTypes;
|
||||
}
|
||||
if (body.globalOptOut !== undefined) {
|
||||
pref.globalOptOut = body.globalOptOut;
|
||||
}
|
||||
|
||||
// Always refresh identity from session
|
||||
pref.email = email;
|
||||
pref.name = name;
|
||||
pref.company = company;
|
||||
|
||||
await savePreference(pref);
|
||||
|
||||
return NextResponse.json(pref);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
|
||||
const NAMESPACE = "tags";
|
||||
|
||||
// ─── Auth: same Bearer token as address-book ────────────────────────
|
||||
|
||||
function checkBearerAuth(req: NextRequest): boolean {
|
||||
const secret = process.env.ADDRESSBOOK_API_KEY;
|
||||
if (!secret) return false;
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
const token = authHeader?.replace("Bearer ", "");
|
||||
return token === secret;
|
||||
}
|
||||
|
||||
// ─── GET /api/projects ──────────────────────────────────────────────
|
||||
// Read-only. Returns all tags with category = "project".
|
||||
//
|
||||
// Query params:
|
||||
// ?q=<search> → search in label / projectCode
|
||||
// ?company=<companyId> → filter by companyId (beletage, urban-switch, studii-de-teren)
|
||||
// ?id=<uuid> → single project by tag ID
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!checkBearerAuth(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const params = req.nextUrl.searchParams;
|
||||
const id = params.get("id");
|
||||
const q = params.get("q")?.toLowerCase();
|
||||
const company = params.get("company");
|
||||
|
||||
try {
|
||||
// Single project by ID
|
||||
if (id) {
|
||||
const item = await prisma.keyValueStore.findUnique({
|
||||
where: { namespace_key: { namespace: NAMESPACE, key: id } },
|
||||
});
|
||||
if (!item) {
|
||||
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||
}
|
||||
const val = item.value as Record<string, unknown>;
|
||||
if (val.category !== "project") {
|
||||
return NextResponse.json({ error: "Not a project tag" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ project: val });
|
||||
}
|
||||
|
||||
// All project tags
|
||||
const items = await prisma.keyValueStore.findMany({
|
||||
where: { namespace: NAMESPACE },
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
|
||||
let projects: Record<string, unknown>[] = [];
|
||||
for (const item of items) {
|
||||
const val = item.value as Record<string, unknown>;
|
||||
if (!val || val.category !== "project") continue;
|
||||
|
||||
// Company filter
|
||||
if (company && val.companyId !== company) continue;
|
||||
|
||||
// Search filter
|
||||
if (q) {
|
||||
const label = String(val.label ?? "").toLowerCase();
|
||||
const code = String(val.projectCode ?? "").toLowerCase();
|
||||
if (!label.includes(q) && !code.includes(q)) continue;
|
||||
}
|
||||
|
||||
projects.push(val);
|
||||
}
|
||||
|
||||
// Sort by projectCode (B-001, B-002, US-001...) then label
|
||||
projects.sort((a, b) => {
|
||||
const aCode = String(a.projectCode ?? "");
|
||||
const bCode = String(b.projectCode ?? "");
|
||||
if (aCode && bCode) return aCode.localeCompare(bCode);
|
||||
if (aCode) return -1;
|
||||
if (bCode) return 1;
|
||||
return String(a.label ?? "").localeCompare(String(b.label ?? ""), "ro");
|
||||
});
|
||||
|
||||
return NextResponse.json({ projects, total: projects.length });
|
||||
} catch (error) {
|
||||
console.error("Projects GET error:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Registratura Audit API — Read-only access to audit trail.
|
||||
*
|
||||
* GET — Retrieve audit events by entry ID or company.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getAuthSession } from "@/core/auth";
|
||||
import {
|
||||
getAuditHistory,
|
||||
getAuditByCompany,
|
||||
} from "@/modules/registratura/services/audit-service";
|
||||
|
||||
// ── Auth ──
|
||||
|
||||
interface Actor {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
async function authenticateRequest(req: NextRequest): Promise<Actor | null> {
|
||||
const session = await getAuthSession();
|
||||
if (session?.user) {
|
||||
const u = session.user as { id?: string; name?: string | null; email?: string | null };
|
||||
return {
|
||||
id: u.id ?? u.email ?? "unknown",
|
||||
name: u.name ?? u.email ?? "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
const apiKey = process.env.REGISTRY_API_KEY;
|
||||
if (apiKey) {
|
||||
const auth = req.headers.get("authorization");
|
||||
if (auth === `Bearer ${apiKey}`) {
|
||||
return { id: "api-key", name: "ERP Integration" };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── GET — Audit history ──
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const actor = await authenticateRequest(req);
|
||||
if (!actor) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const entryId = url.searchParams.get("entryId");
|
||||
const company = url.searchParams.get("company");
|
||||
const from = url.searchParams.get("from");
|
||||
const to = url.searchParams.get("to");
|
||||
const limit = url.searchParams.get("limit");
|
||||
|
||||
try {
|
||||
if (entryId) {
|
||||
const events = await getAuditHistory(entryId);
|
||||
return NextResponse.json({ success: true, events, total: events.length });
|
||||
}
|
||||
|
||||
if (company) {
|
||||
const events = await getAuditByCompany(company, {
|
||||
from: from ?? undefined,
|
||||
to: to ?? undefined,
|
||||
limit: limit ? parseInt(limit, 10) : undefined,
|
||||
});
|
||||
return NextResponse.json({ success: true, events, total: events.length });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Provide entryId or company query parameter" },
|
||||
{ status: 400 },
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Internal error";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Debug endpoint for registry sequence counters.
|
||||
*
|
||||
* GET — Show all sequence counters + actual max from entries + sample numbers
|
||||
* POST — Reset all counters to match actual entries (fixes stale counters)
|
||||
*
|
||||
* Auth: NextAuth session only.
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import { getAuthSession } from "@/core/auth";
|
||||
|
||||
async function requireAdmin(): Promise<NextResponse | null> {
|
||||
const session = await getAuthSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const u = session.user as { role?: string } | undefined;
|
||||
if (u?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Admin access required" }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
|
||||
// Get all sequence counters
|
||||
const counters = await prisma.$queryRaw<
|
||||
Array<{ company: string; year: number; type: string; lastSeq: number }>
|
||||
>`SELECT company, year, type, "lastSeq" FROM "RegistrySequence" ORDER BY company, year, type`;
|
||||
|
||||
// Sample: show raw value snippet + extracted number (for debugging regex issues)
|
||||
const samples = await prisma.$queryRawUnsafe<
|
||||
Array<{ key: string; num: string | null; snippet: string }>
|
||||
>(`
|
||||
SELECT key,
|
||||
SUBSTRING(value::text FROM '"number": "([^"]+)"') AS num,
|
||||
SUBSTRING(value::text FROM 1 FOR 200) AS snippet
|
||||
FROM "KeyValueStore"
|
||||
WHERE namespace = 'registratura'
|
||||
AND key LIKE 'entry:%'
|
||||
ORDER BY key
|
||||
LIMIT 5
|
||||
`);
|
||||
|
||||
// Get actual max sequences from entries — current format: B-2026-00001
|
||||
// Use [0-9] instead of \d for PostgreSQL POSIX regex compatibility
|
||||
const actuals = await prisma.$queryRawUnsafe<
|
||||
Array<{ prefix: string; maxSeq: number; count: number }>
|
||||
>(`
|
||||
SELECT
|
||||
SUBSTRING(value::text FROM '"number": "([A-Z]-[0-9]{4})-') AS prefix,
|
||||
MAX(CAST(SUBSTRING(value::text FROM '"number": "[A-Z]-[0-9]{4}-([0-9]{5})"') AS INTEGER)) AS "maxSeq",
|
||||
COUNT(*)::int AS count
|
||||
FROM "KeyValueStore"
|
||||
WHERE namespace = 'registratura'
|
||||
AND key LIKE 'entry:%'
|
||||
AND value::text ~ '"number": "[A-Z]-[0-9]{4}-[0-9]{5}"'
|
||||
GROUP BY prefix
|
||||
ORDER BY prefix
|
||||
`);
|
||||
|
||||
// Also check for old-format entries (BTG-2026-OUT-00001)
|
||||
const oldFormatActuals = await prisma.$queryRawUnsafe<
|
||||
Array<{ prefix: string; maxSeq: number; count: number }>
|
||||
>(`
|
||||
SELECT
|
||||
SUBSTRING(value::text FROM '"number": "([A-Z]{3}-[0-9]{4})-') AS prefix,
|
||||
MAX(CAST(SUBSTRING(value::text FROM '"number": "[A-Z]{3}-[0-9]{4}-(?:IN|OUT|INT)-([0-9]{5})"') AS INTEGER)) AS "maxSeq",
|
||||
COUNT(*)::int AS count
|
||||
FROM "KeyValueStore"
|
||||
WHERE namespace = 'registratura'
|
||||
AND key LIKE 'entry:%'
|
||||
AND value::text ~ '"number": "[A-Z]{3}-[0-9]{4}-(IN|OUT|INT)-[0-9]{5}"'
|
||||
GROUP BY prefix
|
||||
ORDER BY prefix
|
||||
`);
|
||||
|
||||
return NextResponse.json({
|
||||
counters,
|
||||
samples,
|
||||
currentFormatEntries: actuals,
|
||||
oldFormatEntries: oldFormatActuals,
|
||||
note: "POST to this endpoint to reset all counters to match actual entries",
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
|
||||
// Delete ALL old counters
|
||||
const deleted = await prisma.$executeRaw`DELETE FROM "RegistrySequence"`;
|
||||
|
||||
// Re-create counters from current format entries (B-2026-00001)
|
||||
const insertedNew = await prisma.$executeRawUnsafe(`
|
||||
INSERT INTO "RegistrySequence" (id, company, year, type, "lastSeq", "createdAt", "updatedAt")
|
||||
SELECT
|
||||
gen_random_uuid()::text,
|
||||
SUBSTRING(value::text FROM '"number": "([A-Z])-') AS company,
|
||||
CAST(SUBSTRING(value::text FROM '"number": "[A-Z]-([0-9]{4})-') AS INTEGER) AS year,
|
||||
'SEQ' AS type,
|
||||
MAX(CAST(SUBSTRING(value::text FROM '"number": "[A-Z]-[0-9]{4}-([0-9]{5})"') AS INTEGER)) AS "lastSeq",
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM "KeyValueStore"
|
||||
WHERE namespace = 'registratura'
|
||||
AND key LIKE 'entry:%'
|
||||
AND value::text ~ '"number": "[A-Z]-[0-9]{4}-[0-9]{5}"'
|
||||
GROUP BY company, year, type
|
||||
`);
|
||||
|
||||
// Also handle old-format entries (BTG→B, USW→U, SDT→S, GRP→G)
|
||||
const insertedOld = await prisma.$executeRawUnsafe(`
|
||||
INSERT INTO "RegistrySequence" (id, company, year, type, "lastSeq", "createdAt", "updatedAt")
|
||||
SELECT
|
||||
gen_random_uuid()::text,
|
||||
CASE SUBSTRING(value::text FROM '"number": "([A-Z]{3})-')
|
||||
WHEN 'BTG' THEN 'B'
|
||||
WHEN 'USW' THEN 'U'
|
||||
WHEN 'SDT' THEN 'S'
|
||||
WHEN 'GRP' THEN 'G'
|
||||
ELSE SUBSTRING(value::text FROM '"number": "([A-Z]{3})-')
|
||||
END AS company,
|
||||
CAST(SUBSTRING(value::text FROM '"number": "[A-Z]{3}-([0-9]{4})-') AS INTEGER) AS year,
|
||||
'SEQ' AS type,
|
||||
MAX(CAST(SUBSTRING(value::text FROM '"number": "[A-Z]{3}-[0-9]{4}-(?:IN|OUT|INT)-([0-9]{5})"') AS INTEGER)) AS "lastSeq",
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM "KeyValueStore"
|
||||
WHERE namespace = 'registratura'
|
||||
AND key LIKE 'entry:%'
|
||||
AND value::text ~ '"number": "[A-Z]{3}-[0-9]{4}-(IN|OUT|INT)-[0-9]{5}"'
|
||||
GROUP BY company, year, type
|
||||
ON CONFLICT (company, year, type)
|
||||
DO UPDATE SET "lastSeq" = GREATEST("RegistrySequence"."lastSeq", EXCLUDED."lastSeq"),
|
||||
"updatedAt" = NOW()
|
||||
`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
deletedCounters: deleted,
|
||||
recreatedFromNewFormat: insertedNew,
|
||||
recreatedFromOldFormat: insertedOld,
|
||||
message: "All counters reset from actual entries (both old and new format).",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH — Migrate old-format entries (BTG/SDT/USW/GRP) to new format (B/S/U/G).
|
||||
* Rewrites the "number" field inside the JSONB value for matching entries.
|
||||
*/
|
||||
export async function PATCH() {
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
|
||||
// Map old 3-letter prefixes to new single-letter
|
||||
const migrations: Array<{ old: string; new: string }> = [
|
||||
{ old: "BTG", new: "B" },
|
||||
{ old: "SDT", new: "S" },
|
||||
{ old: "USW", new: "U" },
|
||||
{ old: "GRP", new: "G" },
|
||||
];
|
||||
|
||||
const results: Array<{ prefix: string; updated: number }> = [];
|
||||
|
||||
for (const m of migrations) {
|
||||
// Find entries with old-format numbers: BTG-2026-IN-00001, SDT-2026-OUT-00002, etc.
|
||||
const entries = await prisma.$queryRawUnsafe<
|
||||
Array<{ key: string; num: string }>
|
||||
>(`
|
||||
SELECT key,
|
||||
SUBSTRING(value::text FROM '"number": "([^"]+)"') AS num
|
||||
FROM "KeyValueStore"
|
||||
WHERE namespace = 'registratura'
|
||||
AND key LIKE 'entry:%'
|
||||
AND value::text ~ '"number": "${m.old}-[0-9]{4}-(IN|OUT|INT)-[0-9]{5}"'
|
||||
`);
|
||||
|
||||
let updated = 0;
|
||||
for (const entry of entries) {
|
||||
if (!entry.num) continue;
|
||||
|
||||
// Parse: SDT-2026-OUT-00001 → S-2026-00001
|
||||
const match = entry.num.match(
|
||||
new RegExp(`^${m.old}-(\\d{4})-(?:IN|OUT|INT)-(\\d{5})$`)
|
||||
);
|
||||
if (!match) continue;
|
||||
|
||||
const newNumber = `${m.new}-${match[1]}-${match[2]}`;
|
||||
|
||||
// Update the JSONB value — replace the number field
|
||||
await prisma.$executeRawUnsafe(`
|
||||
UPDATE "KeyValueStore"
|
||||
SET value = jsonb_set(value, '{number}', $1::jsonb)
|
||||
WHERE namespace = 'registratura'
|
||||
AND key = $2
|
||||
`, JSON.stringify(newNumber), entry.key);
|
||||
|
||||
updated++;
|
||||
}
|
||||
|
||||
results.push({ prefix: m.old, updated });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
migrations: results,
|
||||
message: "Old-format entries migrated to new format. Run POST to reset counters.",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Reserved Slots API — Generate and list reserved registration slots.
|
||||
*
|
||||
* POST — Generate reserved slots for a company + month
|
||||
* GET — List reserved slots (with claimed/unclaimed status)
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import { getAuthSession } from "@/core/auth";
|
||||
import {
|
||||
generateReservedSlots,
|
||||
countReservedSlots,
|
||||
} from "@/modules/registratura/services/reserved-slots-service";
|
||||
import { logAuditEvent } from "@/modules/registratura/services/audit-service";
|
||||
import type { RegistryEntry } from "@/modules/registratura/types";
|
||||
import type { CompanyId } from "@/core/auth/types";
|
||||
|
||||
const NAMESPACE = "registratura";
|
||||
const STORAGE_PREFIX = "entry:";
|
||||
|
||||
// ── Auth (same as main route) ──
|
||||
|
||||
interface Actor {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
async function authenticateRequest(req: NextRequest): Promise<Actor | null> {
|
||||
const session = await getAuthSession();
|
||||
if (session?.user) {
|
||||
const u = session.user as { id?: string; name?: string | null; email?: string | null };
|
||||
return {
|
||||
id: u.id ?? u.email ?? "unknown",
|
||||
name: u.name ?? u.email ?? "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
const apiKey = process.env.REGISTRY_API_KEY;
|
||||
if (apiKey) {
|
||||
const auth = req.headers.get("authorization");
|
||||
if (auth === `Bearer ${apiKey}`) {
|
||||
return { id: "api-key", name: "ERP Integration" };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
async function loadAllEntries(): Promise<RegistryEntry[]> {
|
||||
const rows = await prisma.keyValueStore.findMany({
|
||||
where: { namespace: NAMESPACE },
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
const entries: RegistryEntry[] = [];
|
||||
for (const row of rows) {
|
||||
if (row.key.startsWith(STORAGE_PREFIX) && row.value) {
|
||||
entries.push(row.value as unknown as RegistryEntry);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ── POST — Generate reserved slots ──
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const actor = await authenticateRequest(req);
|
||||
if (!actor) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { company, year, month } = body as {
|
||||
company: CompanyId;
|
||||
year: number;
|
||||
month: number; // 0-indexed
|
||||
};
|
||||
|
||||
if (!company || year == null || month == null) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields: company, year, month" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (month < 0 || month > 11) {
|
||||
return NextResponse.json(
|
||||
{ error: "Month must be 0-11 (0-indexed)" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check if slots already exist for this company+month
|
||||
const allEntries = await loadAllEntries();
|
||||
const existing = countReservedSlots(allEntries, company, year, month);
|
||||
if (existing >= 2) {
|
||||
return NextResponse.json(
|
||||
{ error: "Reserved slots already exist for this month", existingCount: existing },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
// Generate slots
|
||||
const slots = await generateReservedSlots(
|
||||
company,
|
||||
year,
|
||||
month,
|
||||
actor.id,
|
||||
actor.name,
|
||||
);
|
||||
|
||||
// Save to KeyValueStore
|
||||
for (const slot of slots) {
|
||||
await prisma.keyValueStore.upsert({
|
||||
where: {
|
||||
namespace_key: {
|
||||
namespace: NAMESPACE,
|
||||
key: `${STORAGE_PREFIX}${slot.id}`,
|
||||
},
|
||||
},
|
||||
update: { value: slot as unknown as Prisma.InputJsonValue },
|
||||
create: {
|
||||
namespace: NAMESPACE,
|
||||
key: `${STORAGE_PREFIX}${slot.id}`,
|
||||
value: slot as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
entryId: slot.id,
|
||||
entryNumber: slot.number,
|
||||
action: "reserved_created",
|
||||
actor: actor.id,
|
||||
actorName: actor.name,
|
||||
company,
|
||||
detail: { date: slot.date, month, year },
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, slots }, { status: 201 });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Internal error";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET — List reserved slots ──
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const actor = await authenticateRequest(req);
|
||||
if (!actor) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const company = url.searchParams.get("company");
|
||||
const year = url.searchParams.get("year");
|
||||
const month = url.searchParams.get("month");
|
||||
|
||||
const allEntries = await loadAllEntries();
|
||||
let reserved = allEntries.filter((e) => e.isReserved === true);
|
||||
|
||||
if (company) reserved = reserved.filter((e) => e.company === company);
|
||||
if (year) {
|
||||
const yr = parseInt(year, 10);
|
||||
reserved = reserved.filter((e) => new Date(e.date).getFullYear() === yr);
|
||||
}
|
||||
if (month) {
|
||||
const m = parseInt(month, 10);
|
||||
reserved = reserved.filter((e) => new Date(e.date).getMonth() === m);
|
||||
}
|
||||
|
||||
reserved.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
return NextResponse.json({ success: true, slots: reserved, total: reserved.length });
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user