diff --git a/CLAUDE.md b/CLAUDE.md index 677ee0a..6466b1a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,463 +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 two on-premise servers, containerized with Docker, managed via Portainer CE. - -### 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) | -| Proxy | Traefik v3 on `10.10.10.199` (proxy server), SSL via Let's Encrypt | -| Deploy | Docker multi-stage, Portainer CE on `10.10.10.166` (satra) | -| 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 -│ ├── / -│ │ ├── 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.5.0 | CRUD registry, dynamic doc types, bidirectional Address Book, threads, backdating, **legal deadline tracking** (6 categories, 18 types), 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), **email notifications** (Brevo SMTP daily digest, per-user preferences), **compact registry numbers** (single-letter company badge + direction arrow + plain number) | -| 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.2.0 | CRUD contacts (person OR institution), card grid, vCard export, Registratura reverse lookup, **dynamic types (creatable)**, **name OR company required** (flexible validation), **ContactPerson with department field**, **quick contact from Registratura** (persons + institutions) | -| 8 | **Password Vault** | `/password-vault` | 0.4.0 | CRUD credentials, 9 categorii cu iconițe, **WiFi QR code real**, context-aware form, strength meter, company scope, **AES-256-GCM encryption**, **utilizatori multipli per intrare** (VaultUser[]: username/password/email/notes, colapsibil în form, badge în list) | -| 9 | **Mini Utilities** | `/mini-utilities` | 0.4.0 | Text case, char counter, percentage, **TVA calculator (cotă configurabilă: 5/9/19/21% + custom)**, area converter, U→R, num→text, artifact cleaner, MDLPA, **PDF compression** (qpdf local lossless + iLovePDF API cloud lossy, streaming upload for large files), PDF unlock, **DWG→DXF**, OCR, color palette, **paste on all drop zones**, **thermal drag-drop reorder**, **Calculator scară desen** (real cm↔desen mm, 7 preseturi 1:20..1:5000 + custom) | -| 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.6.0 | eTerra ANCPI integration, **PostGIS database**, background sync, 23-layer catalog, enrichment pipeline, owner search, **per-UAT analytics dashboard**, **health check + maintenance detection**, **ANCPI ePay CF extract ordering** (batch orders, MinIO PDF storage, dedup protection, credit tracking), **static WORKSPACE_TO_COUNTY mapping**, **GisUat geometry select optimization**, **feature count cache (5-min TTL)** | -| 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: - -- **18 deadline types** across 6 categories (Certificat, Avize, Completari, Urbanism, Autorizare, Litigii) -- **Working days vs calendar days** with Romanian public holiday support (including Orthodox Easter via Meeus algorithm) -- **Chain deadlines** (resolving one prompts adding the next — e.g., CU analiza → emitere, PUZ/PUD analiza → post-CTATU → emitere) -- **Tacit approval** (auto-detected when overdue + applicable type) -- **Tabbed UI**: "Registru" tab (existing registry) + "Termene legale" tab (deadline dashboard) -- **Email notifications**: daily digest via Brevo SMTP, per-user opt-in/opt-out preferences, N8N cron trigger - -Key files: - -- `services/working-days.ts` — Romanian holidays, `addWorkingDays()`, `isWorkingDay()` -- `services/deadline-catalog.ts` — 18 `DeadlineTypeDef` entries across 6 categories -- `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) -- `components/notification-preferences.tsx` — Bell button + dialog with per-type toggles -- `components/registry-table.tsx` — `CompactNumber` component: single-letter company badge (B/U/S/G), direction arrow (↓ intrat / ↑ iesit), plain number - -### Address Book — Flexible Contact Model - -The Address Book supports both persons and institutions: - -- **Flexible validation**: either `name` OR `company` required (not both mandatory) -- **Auto-type detection**: when only company is set via quick-create, type defaults to "institution" -- **ContactPerson sub-entities**: each has `name`, `department`, `role`, `email`, `phone` -- **Quick contact creation from Registratura**: inline dialog with name + company + phone + email -- **Display logic**: if no name, company shows as primary; if both, shows "Name (Company)" -- **Creatable types**: dropdown with defaults (client/supplier/institution/collaborator/internal) + user-created custom types - -Key files: - -- `modules/address-book/types.ts` — `AddressContact`, `ContactPerson` interfaces -- `modules/address-book/components/address-book-module.tsx` — Full UI (cards, detail dialog, form) -- `modules/address-book/hooks/use-contacts.ts` — Storage hook with search/filter -- `modules/address-book/services/vcard-export.ts` — vCard 3.0 export -- `modules/registratura/components/quick-contact-dialog.tsx` — Quick create from registry - -### PDF Compression — Dual Mode (Local + Cloud) - -Two compression routes, both with streaming upload support for large files (tested up to 287MB): - -- **Local (qpdf)**: lossless structural optimization — stream compression, object dedup, linearization. Safe, no font corruption. Typical reduction: 3-15%. -- **Cloud (iLovePDF API)**: lossy image re-compression via iLovePDF REST API. Levels: extreme/recommended/low. Typical reduction: 50-91%. Requires `ILOVEPDF_PUBLIC_KEY` env var. - -**Architecture** (zero-memory for any file size): -1. `parseMultipartUpload()` streams request body to disk (constant 64KB memory) -2. Scans raw file for multipart boundaries using `findInFile()` with 64KB sliding window -3. Stream-copies PDF bytes to separate file -4. Route handler processes (qpdf exec or iLovePDF API) and streams response back - -**Critical gotchas**: -- Middleware body buffering: `api/compress-pdf` routes are **excluded from middleware matcher** (middleware buffers entire body at 10MB default) -- Auth: route-level `requireAuth()` instead of middleware (in `auth-check.ts`) -- Unicode filenames: `Content-Disposition` header uses `encodeURIComponent()` to avoid ByteString errors with Romanian chars (Ș, Ț, etc.) -- Ghostscript `-sDEVICE=pdfwrite` destroys font encodings — **never use GS for compression**, only qpdf - -Key files: - -- `app/api/compress-pdf/parse-upload.ts` — Streaming multipart parser (zero memory) -- `app/api/compress-pdf/extreme/route.ts` — qpdf local compression -- `app/api/compress-pdf/cloud/route.ts` — iLovePDF API integration -- `app/api/compress-pdf/auth-check.ts` — Shared auth for routes excluded from middleware - -### Email Notifications (Brevo SMTP) - -Platform-level notification service for daily email digests: - -- **Brevo SMTP relay** via nodemailer (port 587, STARTTLS) -- **N8N cron**: weekdays 8:00 → POST `/api/notifications/digest` with Bearer token -- **Per-user preferences**: stored in KeyValueStore (`notifications` namespace), toggle global opt-out + 3 notification types -- **Digest content**: urgent deadlines (<=5 days), overdue deadlines, expiring documents (CU/AC) -- **HTML email**: inline-styled table layout, color-coded rows (red/yellow/blue), per-company grouping -- **Sender**: "Alerte Termene" <noreply@beletage.ro>, test mode via `?test=true` query param - -Key files: - -- `src/core/notifications/types.ts` — `NotificationType`, `NotificationPreference`, `DigestSection`, `DigestItem` -- `src/core/notifications/email-service.ts` — Nodemailer transport singleton (Brevo SMTP) -- `src/core/notifications/notification-service.ts` — `runDigest()`, `buildCompanyDigest()`, `renderDigestHtml()`, preference CRUD -- `src/app/api/notifications/digest/route.ts` — POST endpoint (N8N cron, Bearer auth) -- `src/app/api/notifications/preferences/route.ts` — GET/PUT (user session auth) - -### 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 -- **ANCPI ePay CF extract ordering**: batch orders via `epay-client.ts`, PDF storage to MinIO, dedup protection (queue + API level), credit tracking -- **Static county mapping**: `WORKSPACE_TO_COUNTY` in `county-refresh.ts` — 42 verified entries, preferred over unreliable nomenclature API -- **Performance**: GisUat queries use `select` to exclude geometry column; feature counts cached 5-min TTL -- **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 -- `services/epay-client.ts` — ePay HTTP client (login, cart, metadata, submit, poll, download) -- `services/epay-queue.ts` — Batch queue with dedup protection -- `services/epay-storage.ts` — MinIO storage helpers for CF extract PDFs -- `services/epay-counties.ts` — County index mapping (eTerra county name → ePay alphabetical index 0-41) -- `app/api/eterra/session/county-refresh.ts` — Static `WORKSPACE_TO_COUNTY` mapping, LIMITE_UAT geometry refresh -- `components/parcel-sync-module.tsx` — Main UI (~4100 lines), 5 tabs (Export/Layers/Search/DB/Extrase CF) -- `components/uat-dashboard.tsx` — Per-UAT analytics dashboard (CSS-only charts) -- `components/epay-tab.tsx` — CF extract ordering tab -- `components/epay-connect.tsx` — ePay connection widget - ---- - -## Infrastructure - -### Server: `satra` — 10.10.10.166 (Ubuntu, app server) - -| Service | Port | Purpose | -| ----------------------- | ---------------------- | ----------------------------------- | -| **ArchiTools** | 3000 | This app (tools.beletage.ro) | -| **Gitea** | 3002 | Git hosting (git.beletage.ro) | -| **PostgreSQL** | 5432 | App database (Prisma ORM) | -| **Portainer CE** | 9000 | Docker management + deploy | -| **Uptime Kuma** | 3001 | Service monitoring | -| **MinIO** | 9002 (API) / 9003 (UI) | Object storage | -| **Authentik** | 9100 | SSO (auth.beletage.ro) — **active** | -| **N8N** | 5678 | Workflow automation (daily digest) | -| **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 | - -### Server: `proxy` — 10.10.10.199 (Traefik reverse proxy) - -| Config | Path / Value | -| ----------------------- | ---------------------------------------- | -| **Static config** | `/opt/traefik/traefik.yml` | -| **Dynamic configs** | `/opt/traefik/dynamic/` (file provider, `watch: true`) | -| **ArchiTools route** | `/opt/traefik/dynamic/tools.yml` | -| **SSL** | Let's Encrypt ACME, HTTP challenge | -| **Timeouts** | `readTimeout: 600s`, `writeTimeout: 600s`, `idleTimeout: 600s` on `websecure` entrypoint | -| **Response forwarding** | `flushInterval: 100ms` (streaming support) | - -**IMPORTANT**: Default Traefik v2.11+ has 60s `readTimeout` — breaks large file uploads. Must set explicitly in static config. - -### Deployment Pipeline - -``` -git push origin main - → Gitea webhook fires - → Portainer CE: Stacks → architools → "Pull and redeploy" - → Toggle "Re-pull image and redeploy" ON → click "Update" - → Portainer re-clones git repo + Docker multi-stage build (~2 min) - → Container starts on :3000 - → Traefik routes tools.beletage.ro → http://10.10.10.166:3000 -``` - -**Portainer CE deploy**: NOT automatic. Must manually click "Pull and redeploy" in Portainer UI after each push. The stack is configured from git repo `http://10.10.10.166:3002/gitadmin/ArchiTools`. - -### Docker - -- `Dockerfile`: 3-stage build (deps → builder → runner), `node:22-alpine`, non-root user -- Runner stage installs: `gdal gdal-tools ghostscript qpdf` (for PDF compression, GIS) -- `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[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// -├── components/ # React components -├── hooks/ # Custom hooks (use-.ts) -├── services/ # Business logic (pure functions) -├── types.ts # TypeScript interfaces -├── config.ts # ModuleConfig metadata -└── index.ts # Public exports -``` - -### Middleware & Large Upload Routes - -- Next.js middleware buffers the **entire request body** even if it only reads cookies/headers -- Default middleware body limit is 10MB — any upload route handling large files MUST be excluded -- Excluded routes pattern in `src/middleware.ts` matcher: `api/auth|api/notifications/digest|api/compress-pdf` -- Excluded routes handle auth via `requireAuth()` helper (`src/app/api/compress-pdf/auth-check.ts`) -- To add a new large-upload route: (1) add to middleware matcher exclusion, (2) add `requireAuth()` call in route handler -- `next.config.ts` has `experimental: { middlewareClientMaxBodySize: '500mb' }` but this is unreliable with `output: 'standalone'` - -### 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 -- **WORKSPACE_TO_COUNTY is the authoritative county mapping** — static 42-entry map in `county-refresh.ts`, preferred over `fetchCounties()` which 404s intermittently -- **GisUat.geometry is huge** — always use Prisma `select` to exclude it in list queries; forgetting this turns 50ms into 5+ seconds -- **Feature counts are expensive** — cached in global with 5-min TTL in UATs route; returns stale data while refreshing - -### ANCPI ePay Rules - -- **ePay county IDs = eTerra WORKSPACE_IDs** (CLUJ=127, ALBA=10) — zero discovery calls needed -- **ePay UAT IDs = SIRUTA codes** — use `GisUat.workspacePk` + `siruta` directly -- **EpayJsonInterceptor uses form-urlencoded** (NOT JSON body) — `reqType=nomenclatorUAT&countyId=127` -- **saveProductMetadataForBasketItem uses multipart/form-data** (form-data npm package) -- **Document IDs are HTML-encoded** in ShowOrderDetails — `"idDocument":47301767` must be decoded before JSON parse -- **ePay auth is OpenAM** — gets `AMAuthCookie`, then navigate to `http://` (not https) for JSESSIONID -- **MinIO metadata must be ASCII** — strip diacritics from values before storing -- Env vars: `ANCPI_USERNAME`, `ANCPI_PASSWORD`, `ANCPI_BASE_URL`, `ANCPI_LOGIN_URL`, `ANCPI_DEFAULT_SOLICITANT_ID`, `MINIO_BUCKET_ANCPI` - -### 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 | -| **ANCPI ePay** | ✅ Active | CF extract ordering, `epay-client.ts`, MinIO PDF storage, batch queue + dedup, `/api/ancpi/*` routes | -| **PostGIS** | ✅ Active | `GisFeature` model, geometry storage, spatial queries, used by ParcelSync | -| **Email Notifications** | ✅ Implemented | Brevo SMTP daily digest, `/api/notifications/digest` + `/preferences`, N8N cron trigger | -| **N8N automations** | ✅ Active (digest cron) | Daily digest cron `0 8 * * 1-5`, Bearer token auth, future: backups, workflows | -| **iLovePDF API** | ✅ Active | Cloud PDF compression, `ILOVEPDF_PUBLIC_KEY` env, free tier 250 files/month | -| **qpdf** | ✅ Active | Local lossless PDF optimization, installed in Docker image (`apk add qpdf`) | - ---- - -## 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//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// # 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[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/`. diff --git a/docs/ARCHITECTURE-QUICK.md b/docs/ARCHITECTURE-QUICK.md new file mode 100644 index 0000000..b9aad22 --- /dev/null +++ b/docs/ARCHITECTURE-QUICK.md @@ -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 | diff --git a/docs/MODULE-MAP.md b/docs/MODULE-MAP.md new file mode 100644 index 0000000..df98264 --- /dev/null +++ b/docs/MODULE-MAP.md @@ -0,0 +1,140 @@ +# 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), `components/basemap-switcher.tsx`, `components/selection-toolbar.tsx`, `components/feature-info-panel.tsx` +- **API routes**: `/api/geoportal/*` (search, boundary-check, uat-bounds, setup-views) +- **Cross-deps**: **parcel-sync** (declared dependency — uses PostGIS data) + +### Visual CoPilot +- **Route**: `/visual-copilot` +- **Status**: Placeholder (iframe to separate repo `git.beletage.ro/gitadmin/vim`) +- **API routes**: none +- **Cross-deps**: none diff --git a/legacy/emailsignature/emailsignature-config.html b/legacy/emailsignature/emailsignature-config.html deleted file mode 100644 index 6a46719..0000000 --- a/legacy/emailsignature/emailsignature-config.html +++ /dev/null @@ -1,456 +0,0 @@ - - - - - - Configurator semnatura e-mail - - - - - - - - -
-
-

Configurator semnatura e-mail

-
- -
- - - - - -
-
-

Previzualizare Live

- -
-
-
- -
-
-
-
-
- - - - \ No newline at end of file diff --git a/legacy/manicprojects/current manic time Tags.txt b/legacy/manicprojects/current manic time Tags.txt deleted file mode 100755 index 40dbb99..0000000 --- a/legacy/manicprojects/current manic time Tags.txt +++ /dev/null @@ -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 \ No newline at end of file diff --git a/legacy/wordXMLgenerator/word-xml-generator-advanced.html b/legacy/wordXMLgenerator/word-xml-generator-advanced.html deleted file mode 100644 index 8e97444..0000000 --- a/legacy/wordXMLgenerator/word-xml-generator-advanced.html +++ /dev/null @@ -1,694 +0,0 @@ - - - - - Beletage – Word XML Data Engine - - - - - - -
-

Beletage – Word XML Data Engine

-

- Generator de Custom XML Parts pentru Word, pe categorii (Beneficiar, Proiect, Suprafete, Meta etc.), - cu mod Simple / Advanced și câmpuri derivate (Short, Upper, Initials) + POT/CUT pregătite. -

- - -
-
-
- - -
Ex: http://schemas.beletage.ro/contract → pentru categoria „Proiect” devine - http://schemas.beletage.ro/contract/Proiect. -
-
-
- -
-
Simple
-
Advanced
-
-
- Simple: doar câmpurile tale.
- Advanced: + Short / Upper / Lower / Initials / First pentru fiecare câmp. -
-
-
- -
- -
-
-
-
- - -
-
-
- -
- -
- Exemple de organizare: Beneficiar, Proiect, Suprafete, Meta. -
-
- -
- - -
- Un câmp pe linie. Poți edita lista. Butonul „Reset categorie la preset” reîncarcă valorile default pentru - categoria curentă (dacă există). -
-
- - -
-
-
-
-
- - -
-
- - - -
-
- Tip - În Word, fiecare fișier generat devine un Custom XML Part separat (ex: BeneficiarData.xml, - ProiectData.xml etc.), perfect pentru organizarea mapping-urilor. -
-
- - -
-

Preview XML & XPaths

-
- Selectează o categorie pentru a vedea XML-ul și XPaths-urile aferente. -
-
-
-
XML categorie curentă
-

-      
-
-
XPaths categorie curentă
-

-      
-
-
-
- - - - diff --git a/legacy/wordXMLgenerator/word-xml-generator-basic.html b/legacy/wordXMLgenerator/word-xml-generator-basic.html deleted file mode 100644 index 7afc776..0000000 --- a/legacy/wordXMLgenerator/word-xml-generator-basic.html +++ /dev/null @@ -1,151 +0,0 @@ - - - - - Generator XML Word – Versiune Extinsă - - - - - -

Generator Word XML – Varianta Extinsă (cu Short / Upper / Lower / Initials)

- -
- - - - - - - - - - -
- -
-

Custom XML Part (item1.xml)

-

-  
-
- -
-

XPaths pentru mapping

-

-
- - - - - diff --git a/legacy/wordXMLgenerator/word-xml-generator-medium.html b/legacy/wordXMLgenerator/word-xml-generator-medium.html deleted file mode 100644 index 337cb46..0000000 --- a/legacy/wordXMLgenerator/word-xml-generator-medium.html +++ /dev/null @@ -1,330 +0,0 @@ - - - - - Generator Word XML Custom Part - - - - -
-

Generator XML pentru Word Custom XML Part

-

- Introdu câmpurile (unul pe linie) și obții XML pentru Custom XML Part, plus XPaths pentru mapping în Word. -

- -
-
-
- - -
- Exemplu: http://schemas.firma-ta.ro/word/contract -
-
-
- - -
- Exemplu: ContractData, ClientInfo etc. -
-
-
- -
- - -
- Numele va fi curățat automat pentru a fi valid ca nume de element XML - (spațiile devin _, caracterele ciudate se elimină). -
-
- -
- - -
-
- -
-
1Custom XML Part (item1.xml)
-

-    
- - -
-
- -
-
2XPaths pentru mapping în Word
-

-    
-    

- În Word → DeveloperXML Mapping Pane → alegi Custom XML Part-ul - → pentru fiecare câmp, click dreapta → Insert Content Control → tipul dorit. -

-
-
- - - - diff --git a/next.config.ts b/next.config.ts index 6f0f03d..c97bfa7 100644 --- a/next.config.ts +++ b/next.config.ts @@ -6,6 +6,19 @@ const nextConfig: NextConfig = { 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 [ diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 772b8be..35a9259 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -42,7 +42,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]) diff --git a/src/app/(modules)/rgi-test/page.tsx b/src/app/(modules)/rgi-test/page.tsx deleted file mode 100644 index 9aa5417..0000000 --- a/src/app/(modules)/rgi-test/page.tsx +++ /dev/null @@ -1,1086 +0,0 @@ -"use client"; - -import React, { useState, useCallback, useMemo, useEffect, useRef } from "react"; -import JSZip from "jszip"; -import { Button } from "@/shared/components/ui/button"; -import { Input } from "@/shared/components/ui/input"; -import { Label } from "@/shared/components/ui/label"; -import { Badge } from "@/shared/components/ui/badge"; -import { Card, CardContent } from "@/shared/components/ui/card"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/shared/components/ui/select"; -import { - Loader2, - ChevronDown, - ChevronUp, - Download, - Search, - FileText, - CheckCircle2, - Clock, - AlertTriangle, - Settings2, - Shield, - ArrowUpDown, - ArrowUp, - ArrowDown, - Archive, -} from "lucide-react"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/shared/components/ui/tooltip"; -import { cn } from "@/shared/lib/utils"; - -/* ------------------------------------------------------------------ */ -/* Types */ -/* ------------------------------------------------------------------ */ - -type App = { - actorName: string; - adminUnit: number; - appDate: number; - appNo: number; - applicationObject: string; - applicationPk: number; - colorNumber: number; - communicationType: string; - deponent: string; - dueDate: number; - hasSolution: number; - identifiers: string; - initialAppNo: string; - orgUnit: string; - requester: string; - resolutionName: string; - stateCode: string; - statusName: string; - totalFee: number; - uat: string; - workspace: string; - workspaceId: number; - [key: string]: unknown; -}; - -type IssuedDoc = { - applicationId: number; - docType: string; - documentPk: number; - documentTypeCode: string; - documentTypeId: number; - fileExtension: string; - digitallySigned: number; - startDate: number; - lastUpdatedDtm: number; - initialAppNo: string; - workspaceId: number; - identifierDetails: string | null; - [key: string]: unknown; -}; - -type SortDir = "asc" | "desc"; -type SortState = { key: string; dir: SortDir } | null; - -/* ------------------------------------------------------------------ */ -/* County list */ -/* ------------------------------------------------------------------ */ - -const COUNTIES = [ - { id: 10, name: "Alba" }, - { id: 29, name: "Arad" }, - { id: 38, name: "Arges" }, - { id: 47, name: "Bacau" }, - { id: 56, name: "Bihor" }, - { id: 65, name: "Bistrita-Nasaud" }, - { id: 74, name: "Botosani" }, - { id: 83, name: "Brasov" }, - { id: 92, name: "Braila" }, - { id: 108, name: "Buzau" }, - { id: 117, name: "Caras-Severin" }, - { id: 127, name: "Cluj" }, - { id: 136, name: "Constanta" }, - { id: 145, name: "Covasna" }, - { id: 154, name: "Dambovita" }, - { id: 163, name: "Dolj" }, - { id: 172, name: "Galati" }, - { id: 181, name: "Giurgiu" }, - { id: 190, name: "Gorj" }, - { id: 199, name: "Harghita" }, - { id: 208, name: "Hunedoara" }, - { id: 217, name: "Ialomita" }, - { id: 226, name: "Iasi" }, - { id: 235, name: "Ilfov" }, - { id: 244, name: "Maramures" }, - { id: 253, name: "Mehedinti" }, - { id: 262, name: "Mures" }, - { id: 271, name: "Neamt" }, - { id: 280, name: "Olt" }, - { id: 289, name: "Prahova" }, - { id: 298, name: "Satu Mare" }, - { id: 307, name: "Salaj" }, - { id: 316, name: "Sibiu" }, - { id: 325, name: "Suceava" }, - { id: 334, name: "Teleorman" }, - { id: 343, name: "Timis" }, - { id: 352, name: "Tulcea" }, - { id: 361, name: "Vaslui" }, - { id: 370, name: "Valcea" }, - { id: 379, name: "Vrancea" }, - { id: 401, name: "Bucuresti" }, -] as const; - -/* ------------------------------------------------------------------ */ -/* Column definitions */ -/* ------------------------------------------------------------------ */ - -type ColumnDef = { - key: string; - label: string; - defaultVisible: boolean; - render: (app: App) => string; - className?: string; -}; - -function fmtTs(ts: number | null | undefined): string { - if (!ts) return "-"; - const d = new Date(ts); - if (isNaN(d.getTime())) return "-"; - return d.toLocaleDateString("ro-RO", { - day: "2-digit", - month: "2-digit", - year: "numeric", - }); -} - -const ALL_COLUMNS: ColumnDef[] = [ - { - key: "appNo", - label: "Nr. cerere", - defaultVisible: true, - render: (a) => String(a.appNo ?? "-"), - className: "font-mono font-semibold", - }, - { - key: "initialAppNo", - label: "Nr. initial", - defaultVisible: false, - render: (a) => a.initialAppNo || "-", - className: "font-mono text-xs", - }, - { - key: "applicationObject", - label: "Obiect", - defaultVisible: false, - render: (a) => a.applicationObject || "-", - }, - { - key: "identifiers", - label: "Identificatori (IE/CF)", - defaultVisible: false, - render: (a) => a.identifiers || "-", - className: "text-xs max-w-[300px] truncate", - }, - { - key: "deponent", - label: "Deponent", - defaultVisible: false, - render: (a) => a.deponent || "-", - }, - { - key: "requester", - label: "Solicitant", - defaultVisible: true, - render: (a) => a.requester || "-", - }, - { - key: "appDate", - label: "Data depunere", - defaultVisible: false, - render: (a) => fmtTs(a.appDate), - className: "tabular-nums", - }, - { - key: "dueDate", - label: "Termen", - defaultVisible: true, - render: (a) => fmtTs(a.dueDate), - className: "tabular-nums", - }, - { - key: "statusName", - label: "Status", - defaultVisible: true, - render: (a) => a.statusName || a.stateCode || "-", - }, - { - key: "resolutionName", - label: "Rezolutie", - defaultVisible: true, - render: (a) => a.resolutionName || "-", - }, - { - key: "hasSolution", - label: "Solutionat", - defaultVisible: false, - render: (a) => (a.hasSolution === 1 ? "DA" : "NU"), - }, - { - key: "totalFee", - label: "Taxa (lei)", - defaultVisible: false, - render: (a) => (a.totalFee != null ? String(a.totalFee) : "-"), - className: "tabular-nums", - }, - { - key: "uat", - label: "UAT", - defaultVisible: true, - render: (a) => a.uat || "-", - }, - { - key: "orgUnit", - label: "OCPI", - defaultVisible: false, - render: (a) => a.orgUnit || "-", - }, - { - key: "communicationType", - label: "Comunicare", - defaultVisible: false, - render: (a) => a.communicationType || "-", - className: "text-xs", - }, - { - key: "actorName", - label: "Actor curent", - defaultVisible: false, - render: (a) => a.actorName || "-", - }, - { - key: "applicationPk", - label: "Application PK", - defaultVisible: false, - render: (a) => String(a.applicationPk ?? "-"), - className: "font-mono text-xs", - }, -]; - -/* ------------------------------------------------------------------ */ -/* Diacritics-insensitive search helper */ -/* ------------------------------------------------------------------ */ - -function removeDiacritics(str: string): string { - return str.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); -} - -function matchesSearch(text: string, query: string): boolean { - return removeDiacritics(text.toLowerCase()).includes( - removeDiacritics(query.toLowerCase()), - ); -} - -/* ------------------------------------------------------------------ */ -/* Filename sanitizer (client-side) */ -/* ------------------------------------------------------------------ */ - -function sanitize(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, ""); -} - -/* ------------------------------------------------------------------ */ -/* Issued Documents panel */ -/* ------------------------------------------------------------------ */ - -function IssuedDocsPanel({ - applicationPk, - workspaceId, - appNo, -}: { - applicationPk: number; - workspaceId: number; - appNo: number; -}) { - const [docs, setDocs] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(""); - const [blockedDocPk, setBlockedDocPk] = useState(null); - const [downloadingAll, setDownloadingAll] = useState(false); - const [downloadProgress, setDownloadProgress] = useState(""); - const blockedTimerRef = useRef | null>(null); - - useEffect(() => { - let cancelled = false; - void (async () => { - try { - const res = await fetch( - `/api/eterra/rgi/issued-docs?applicationId=${applicationPk}&workspaceId=${workspaceId}`, - ); - const data = await res.json(); - const items: IssuedDoc[] = Array.isArray(data) - ? data - : data?.content ?? data?.data ?? data?.list ?? []; - if (!cancelled) setDocs(items); - } catch { - if (!cancelled) setError("Eroare la incarcarea documentelor"); - } - if (!cancelled) setLoading(false); - })(); - return () => { - cancelled = true; - }; - }, [applicationPk, workspaceId]); - - // Cleanup blocked timer on unmount - useEffect(() => { - return () => { - if (blockedTimerRef.current) clearTimeout(blockedTimerRef.current); - }; - }, []); - - const handleDownloadAll = useCallback(async () => { - if (!docs || docs.length === 0 || downloadingAll) return; - setDownloadingAll(true); - const zip = new JSZip(); - let downloaded = 0; - let blocked = 0; - - const typeCounts: Record = {}; - for (const d of docs) typeCounts[d.docType || "Document"] = (typeCounts[d.docType || "Document"] || 0) + 1; - const typeIdx: Record = {}; - - for (const doc of docs) { - const docName = sanitize(doc.docType || doc.documentTypeCode || "Document"); - const ext = (doc.fileExtension || "pdf").toLowerCase(); - const typeKey = doc.docType || "Document"; - typeIdx[typeKey] = (typeIdx[typeKey] || 0) + 1; - const suffix = (typeCounts[typeKey] ?? 0) > 1 ? `_${typeIdx[typeKey]}` : ""; - const filename = `${docName}_${appNo}${suffix}.${ext}`; - setDownloadProgress(`${downloaded + blocked + 1}/${docs.length}: ${doc.docType || "Document"}...`); - - const url = - `/api/eterra/rgi/download-doc?workspaceId=${doc.workspaceId || workspaceId}` + - `&applicationId=${doc.applicationId || applicationPk}` + - `&documentPk=${doc.documentPk}` + - `&documentTypeId=${doc.documentTypeId}` + - `&docType=${encodeURIComponent(doc.docType || "")}` + - `&appNo=${appNo}`; - - try { - const res = await fetch(url); - const ct = res.headers.get("content-type") || ""; - if (ct.includes("application/json")) { blocked++; continue; } - const blob = await res.blob(); - zip.file(filename, blob); - downloaded++; - } catch { - blocked++; - } - } - - if (downloaded > 0) { - setDownloadProgress("Se creeaza arhiva ZIP..."); - const zipBlob = await zip.generateAsync({ type: "blob" }); - const a = document.createElement("a"); - a.href = URL.createObjectURL(zipBlob); - a.download = `Documente_eliberate_${appNo}.zip`; - document.body.appendChild(a); - a.click(); - URL.revokeObjectURL(a.href); - document.body.removeChild(a); - } - - setDownloadProgress( - blocked > 0 - ? `${downloaded} in ZIP, ${blocked} indisponibil${blocked !== 1 ? "e" : ""}` - : `ZIP descarcat cu ${downloaded} document${downloaded !== 1 ? "e" : ""}`, - ); - setDownloadingAll(false); - setTimeout(() => setDownloadProgress(""), 5000); - }, [docs, downloadingAll, workspaceId, applicationPk, appNo]); - - const handleDownload = useCallback( - async (doc: IssuedDoc, e: React.MouseEvent) => { - e.stopPropagation(); - const url = - `/api/eterra/rgi/download-doc?workspaceId=${doc.workspaceId || workspaceId}` + - `&applicationId=${doc.applicationId || applicationPk}` + - `&documentPk=${doc.documentPk}` + - `&documentTypeId=${doc.documentTypeId}` + - `&docType=${encodeURIComponent(doc.docType || doc.documentTypeCode || "Document")}` + - `&appNo=${appNo}`; - - try { - const res = await fetch(url); - const contentType = res.headers.get("content-type") || ""; - - if (contentType.includes("application/json")) { - const json = await res.json(); - if (json.blocked || json.error) { - setBlockedDocPk(doc.documentPk); - if (blockedTimerRef.current) clearTimeout(blockedTimerRef.current); - blockedTimerRef.current = setTimeout(() => setBlockedDocPk(null), 5000); - return; - } - } - - // It's a file — trigger download - const blob = await res.blob(); - const disposition = res.headers.get("content-disposition") || ""; - let filename = `document_${doc.documentPk}.pdf`; - const match = disposition.match(/filename="?([^";\n]+)"?/); - if (match) { - const decoded = match[1]; - if (decoded) filename = decodeURIComponent(decoded); - } - const a = document.createElement("a"); - a.href = URL.createObjectURL(blob); - a.download = filename; - document.body.appendChild(a); - a.click(); - URL.revokeObjectURL(a.href); - document.body.removeChild(a); - } catch { - setBlockedDocPk(doc.documentPk); - if (blockedTimerRef.current) clearTimeout(blockedTimerRef.current); - blockedTimerRef.current = setTimeout(() => setBlockedDocPk(null), 5000); - } - }, - [workspaceId, applicationPk, appNo], - ); - - if (loading) { - return ( -
- - Se incarca documentele... -
- ); - } - - if (error) { - return

{error}

; - } - - if (!docs || docs.length === 0) { - return ( -

- Niciun document eliberat. -

- ); - } - - return ( -
-
-

- {docs.length} document{docs.length > 1 ? "e" : ""} eliberat - {docs.length > 1 ? "e" : ""} -

-
- {downloadProgress && ( - {downloadProgress} - )} - -
-
- {docs.map((doc, i) => ( -
-
-
- -
-

- {doc.docType || doc.documentTypeCode || "Document"} -

-
- {fmtTs(doc.startDate || doc.lastUpdatedDtm)} - - .{(doc.fileExtension || "PDF").toLowerCase()} - - {doc.digitallySigned === 1 && ( - - - semnat - - )} - {doc.identifierDetails && ( - - {doc.identifierDetails} - - )} -
-
-
- -
- {blockedDocPk === doc.documentPk && ( -
- Documentul nu este inca disponibil pentru descarcare din eTerra -
- )} -
- ))} -
- ); -} - -/* ------------------------------------------------------------------ */ -/* Main page */ -/* ------------------------------------------------------------------ */ - -export default function RgiTestPage() { - const [countyId, setCountyId] = useState(127); - const orgUnitId = countyId * 1000 + 2; - const [year, setYear] = useState("2026"); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); - const [applications, setApplications] = useState([]); - const [totalCount, setTotalCount] = useState(0); - const [expandedPk, setExpandedPk] = useState(null); - const [showColumnPicker, setShowColumnPicker] = useState(false); - const [downloadingAppPk, setDownloadingAppPk] = useState(null); - const [filterMode, setFilterMode] = useState<"all" | "solved" | "confirmed">( - "solved", - ); - const [searchQuery, setSearchQuery] = useState(""); - const [sortState, setSortState] = useState({ key: "dueDate", dir: "desc" }); - - // Column visibility - const [visibleCols, setVisibleCols] = useState>( - () => new Set(ALL_COLUMNS.filter((c) => c.defaultVisible).map((c) => c.key)), - ); - - const toggleColumn = (key: string) => { - setVisibleCols((prev) => { - const next = new Set(prev); - if (next.has(key)) next.delete(key); - else next.add(key); - return next; - }); - }; - - const columns = useMemo( - () => ALL_COLUMNS.filter((c) => visibleCols.has(c.key)), - [visibleCols], - ); - - const downloadAllForApp = useCallback(async (app: App) => { - if (downloadingAppPk) return; - setDownloadingAppPk(app.applicationPk); - try { - // Fetch issued docs - const res = await fetch( - `/api/eterra/rgi/issued-docs?applicationId=${app.applicationPk}&workspaceId=${app.workspaceId}`, - ); - const data = await res.json(); - const docs: IssuedDoc[] = Array.isArray(data) - ? data - : data?.content ?? data?.data ?? data?.list ?? []; - - if (docs.length === 0) { - setDownloadingAppPk(null); - return; - } - - // Count by type for dedup naming - const typeCounts: Record = {}; - for (const d of docs) typeCounts[d.docType || "Document"] = (typeCounts[d.docType || "Document"] || 0) + 1; - const typeIdx: Record = {}; - - for (const doc of docs) { - const docName = sanitize(doc.docType || doc.documentTypeCode || "Document"); - const ext = (doc.fileExtension || "pdf").toLowerCase(); - const typeKey = doc.docType || "Document"; - typeIdx[typeKey] = (typeIdx[typeKey] || 0) + 1; - const suffix = (typeCounts[typeKey] ?? 0) > 1 ? `_${typeIdx[typeKey]}` : ""; - const filename = `${docName}_${app.appNo}${suffix}.${ext}`; - - const url = - `/api/eterra/rgi/download-doc?workspaceId=${doc.workspaceId || app.workspaceId}` + - `&applicationId=${doc.applicationId || app.applicationPk}` + - `&documentPk=${doc.documentPk}` + - `&documentTypeId=${doc.documentTypeId}` + - `&docType=${encodeURIComponent(doc.docType || "")}` + - `&appNo=${app.appNo}`; - - try { - const r = await fetch(url); - const ct = r.headers.get("content-type") || ""; - if (ct.includes("application/json")) continue; // blocked - const blob = await r.blob(); - const a = document.createElement("a"); - a.href = URL.createObjectURL(blob); - a.download = filename; - document.body.appendChild(a); - a.click(); - URL.revokeObjectURL(a.href); - document.body.removeChild(a); - await new Promise((resolve) => setTimeout(resolve, 300)); - } catch { - // skip - } - } - } catch { - // silent - } - setDownloadingAppPk(null); - }, [downloadingAppPk]); - - const handleSort = useCallback( - (key: string) => { - setSortState((prev) => { - if (prev && prev.key === key) { - return prev.dir === "asc" ? { key, dir: "desc" } : null; - } - return { key, dir: "asc" }; - }); - }, - [], - ); - - const loadApplications = useCallback(async () => { - setLoading(true); - setError(""); - setApplications([]); - setExpandedPk(null); - try { - const res = await fetch("/api/eterra/rgi/applications", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - workspaceId: countyId, - orgUnitId, - year, - page: 0, - nrElements: 200, - }), - }); - const data = await res.json(); - if (data.error) { - setError(data.error); - return; - } - const items: App[] = Array.isArray(data) - ? data - : data?.content ?? data?.data ?? data?.list ?? []; - setApplications(items); - setTotalCount( - typeof data?.totalElements === "number" - ? data.totalElements - : items.length, - ); - } catch { - setError("Eroare de retea. Verifica conexiunea la eTerra."); - } - setLoading(false); - }, [countyId, orgUnitId, year]); - - // Client-side filter + search + sort pipeline - const processed = useMemo(() => { - // Step 1: Filter by mode - let result = applications; - if (filterMode === "solved") { - result = result.filter((a) => a.hasSolution === 1); - } else if (filterMode === "confirmed") { - result = result.filter((a) => a.stateCode === "CONFIRMED"); - } - - // Step 2: Search across visible columns - if (searchQuery.trim()) { - const q = searchQuery.trim(); - result = result.filter((app) => - columns.some((col) => matchesSearch(col.render(app), q)), - ); - } - - // Step 3: Sort - if (sortState) { - const col = ALL_COLUMNS.find((c) => c.key === sortState.key); - if (col) { - const dir = sortState.dir === "asc" ? 1 : -1; - // Use raw timestamps for date columns - const dateKeys = new Set(["dueDate", "appDate"]); - result = [...result].sort((a, b) => { - if (dateKeys.has(sortState.key)) { - const va = (a[sortState.key] as number) || 0; - const vb = (b[sortState.key] as number) || 0; - return (va - vb) * dir; - } - const va = col.render(a); - const vb = col.render(b); - const na = parseFloat(va); - const nb = parseFloat(vb); - if (!isNaN(na) && !isNaN(nb)) return (na - nb) * dir; - return va.localeCompare(vb, "ro") * dir; - }); - } - } - - return result; - }, [applications, filterMode, searchQuery, columns, sortState]); - - const SortIcon = ({ colKey }: { colKey: string }) => { - if (!sortState || sortState.key !== colKey) { - return ; - } - if (sortState.dir === "asc") { - return ; - } - return ; - }; - - return ( -
- {/* Header */} -
-

Documente Eliberate eTerra

-

- Lucrari depuse cu documente eliberate — descarca direct din eTerra RGI -

-
- - {/* Filters */} - - -
-
- - -
-
- - setYear(e.target.value)} - className="w-20" - /> -
- - -
- - {/* Column picker */} - {showColumnPicker && ( -
- {ALL_COLUMNS.map((col) => ( - - ))} -
- )} - - {/* Filter toggle + search */} -
-
- {( - [ - { - id: "solved" as const, - label: "Solutionate", - desc: "lucrari cu solutie", - }, - { - id: "confirmed" as const, - label: "Confirmate", - desc: "solutie confirmata", - }, - { id: "all" as const, label: "Toate", desc: "" }, - ] as const - ).map((opt) => ( - - ))} -
- - {applications.length > 0 && ( -
-
- - setSearchQuery(e.target.value)} - placeholder="Cauta in rezultate..." - className="pl-8 h-8 text-xs" - /> -
- - {processed.length} din {applications.length} lucrari - {totalCount > applications.length && - ` (${totalCount} total)`} - -
- )} -
-
-
- - {/* Error */} - {error && ( - - - - {error} - - - )} - - {/* Loading */} - {loading && ( - - - -

Se incarca lucrarile din eTerra RGI...

-
-
- )} - - {/* Results table */} - {!loading && processed.length > 0 && ( - - -
- - - - - {columns.map((col) => ( - - ))} - - - - - {processed.map((app) => { - const pk = app.applicationPk; - const isExpanded = expandedPk === pk; - const solved = app.hasSolution === 1; - - return ( - - - setExpandedPk(isExpanded ? null : pk) - } - > - - {columns.map((col) => ( - - ))} - - - {isExpanded && ( - - - - )} - - ); - })} - -
handleSort(col.key)} - > - - {col.label} - - -
- - - - - - -

Descarca arhiva ZIP cu documentele cererii {app.appNo}

-

{app.applicationObject || "-"}

-

Status: {app.statusName || app.stateCode}

-

Rezolutie: {app.resolutionName || "-"}

-

Termen: {fmtTs(app.dueDate)}

- {app.identifiers && ( -

{app.identifiers}

- )} -
-
-
-
- {col.key === "statusName" ? ( - - {col.render(app)} - - ) : col.key === "resolutionName" ? ( - - {col.render(app)} - - ) : ( - col.render(app) - )} - - {isExpanded ? ( - - ) : ( - - )} -
- -
-
-
-
- )} - - {/* Empty states */} - {!loading && applications.length > 0 && processed.length === 0 && ( - - - -

Nicio lucrare gasita pentru filtrul selectat.

-

- Schimba filtrul sau termenul de cautare. -

-
-
- )} - - {!loading && applications.length === 0 && !error && ( - - - -

Apasa "Incarca lucrari" pentru a incepe.

-
-
- )} -
- ); -} diff --git a/src/app/api/compress-pdf/route.ts b/src/app/api/compress-pdf/route.ts index 8f69363..b343c85 100644 --- a/src/app/api/compress-pdf/route.ts +++ b/src/app/api/compress-pdf/route.ts @@ -1,11 +1,20 @@ 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 { // Buffer the full body then forward to Stirling — streaming passthrough // (req.body + duplex:half) is unreliable for large files in Next.js. diff --git a/src/app/api/compress-pdf/unlock/route.ts b/src/app/api/compress-pdf/unlock/route.ts index bcc6b6a..dcf54d0 100644 --- a/src/app/api/compress-pdf/unlock/route.ts +++ b/src/app/api/compress-pdf/unlock/route.ts @@ -1,11 +1,20 @@ 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 { // Stream body directly to Stirling — avoids FormData re-serialization // failure on large files ("Failed to parse body as FormData") diff --git a/src/app/api/geoportal/search/route.ts b/src/app/api/geoportal/search/route.ts index 1b5513f..2349218 100644 --- a/src/app/api/geoportal/search/route.ts +++ b/src/app/api/geoportal/search/route.ts @@ -78,7 +78,7 @@ export async function GET(req: Request) { WHERE geom IS NOT NULL AND "layerId" LIKE 'TERENURI%' AND ("cadastralRef" ILIKE ${pattern} - OR enrichment::text ILIKE ${'%"NR_CAD":"' + q + '%'}) + OR enrichment::text ILIKE ${`%"NR_CAD":"${q}%`}) ORDER BY "cadastralRef" LIMIT ${limit} ` as Array<{ diff --git a/src/app/api/registratura/debug-sequences/route.ts b/src/app/api/registratura/debug-sequences/route.ts index 4a478b2..509e294 100644 --- a/src/app/api/registratura/debug-sequences/route.ts +++ b/src/app/api/registratura/debug-sequences/route.ts @@ -11,11 +11,21 @@ import { NextResponse } from "next/server"; import { prisma } from "@/core/storage/prisma"; import { getAuthSession } from "@/core/auth"; -export async function GET() { +async function requireAdmin(): Promise { 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< @@ -79,10 +89,8 @@ export async function GET() { } export async function POST() { - const session = await getAuthSession(); - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } + const denied = await requireAdmin(); + if (denied) return denied; // Delete ALL old counters const deleted = await prisma.$executeRaw`DELETE FROM "RegistrySequence"`; @@ -146,10 +154,8 @@ export async function POST() { * Rewrites the "number" field inside the JSONB value for matching entries. */ export async function PATCH() { - const session = await getAuthSession(); - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } + const denied = await requireAdmin(); + if (denied) return denied; // Map old 3-letter prefixes to new single-letter const migrations: Array<{ old: string; new: string }> = [ diff --git a/src/app/api/registratura/route.ts b/src/app/api/registratura/route.ts index 603328f..fd06111 100644 --- a/src/app/api/registratura/route.ts +++ b/src/app/api/registratura/route.ts @@ -213,27 +213,33 @@ export async function POST(req: NextRequest) { let claimedSlotId: string | undefined; if (isPastMonth && direction === "intrat") { - // Try to claim a reserved slot - const allEntries = await loadAllEntries(true); - const slot = findAvailableReservedSlot( - allEntries, - company, - docDate.getFullYear(), - docDate.getMonth(), - ); + // Try to claim a reserved slot — use advisory lock to prevent concurrent claims + const lockKey = `reserved:${company}-${docDate.getFullYear()}-${docDate.getMonth()}`; + const claimed = await prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${lockKey}))`; + const allEntries = await loadAllEntries(true); + const slot = findAvailableReservedSlot( + allEntries, + company, + docDate.getFullYear(), + docDate.getMonth(), + ); + if (!slot) return null; + // Delete the placeholder slot within the lock + await tx.keyValueStore.delete({ + where: { namespace_key: { namespace: "registratura", key: slot.id } }, + }); + return slot; + }); - if (slot) { - // Claim the reserved slot — reuse its number - registryNumber = slot.number; + if (claimed) { + registryNumber = claimed.number; registrationType = "reserved-claimed"; - claimedSlotId = slot.id; - - // Delete the placeholder slot - await deleteEntryFromDB(slot.id); + claimedSlotId = claimed.id; await logAuditEvent({ - entryId: slot.id, - entryNumber: slot.number, + entryId: claimed.id, + entryNumber: claimed.number, action: "reserved_claimed", actor: actor.id, actorName: actor.name, diff --git a/src/app/api/storage/route.ts b/src/app/api/storage/route.ts index fe13b0e..fab5369 100644 --- a/src/app/api/storage/route.ts +++ b/src/app/api/storage/route.ts @@ -144,8 +144,8 @@ export async function DELETE(request: NextRequest) { }, }, }) - .catch(() => { - // Ignore error if item doesn't exist + .catch((err: { code?: string }) => { + if (err.code !== "P2025") throw err; }); } else { // Clear namespace diff --git a/src/core/storage/minio-client.ts b/src/core/storage/minio-client.ts index c0e3d56..9ca37ea 100644 --- a/src/core/storage/minio-client.ts +++ b/src/core/storage/minio-client.ts @@ -18,16 +18,3 @@ if (process.env.NODE_ENV !== "production") globalForMinio.minioClient = minioClient; export const MINIO_BUCKET_NAME = process.env.MINIO_BUCKET_NAME || "tools"; - -// Helper to ensure bucket exists -export async function ensureBucketExists() { - try { - const exists = await minioClient.bucketExists(MINIO_BUCKET_NAME); - if (!exists) { - await minioClient.makeBucket(MINIO_BUCKET_NAME, "eu-west-1"); - console.log(`Bucket '${MINIO_BUCKET_NAME}' created successfully.`); - } - } catch (error) { - console.error("Error checking/creating MinIO bucket:", error); - } -} diff --git a/src/middleware.ts b/src/middleware.ts index 1146086..8aef4db 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -16,7 +16,7 @@ export async function middleware(request: NextRequest) { if (token) { const { pathname } = request.nextUrl; // Portal-only users: redirect to /portal when accessing main app - const portalUsers = ["dtiurbe", "d.tiurbe"]; + const portalUsers = (process.env.PORTAL_ONLY_USERS ?? "dtiurbe,d.tiurbe").split(",").map(s => s.trim().toLowerCase()); const tokenEmail = String(token.email ?? "").toLowerCase(); const tokenName = String(token.name ?? "").toLowerCase(); const isPortalUser = portalUsers.some( diff --git a/src/modules/parcel-sync/services/epay-client.ts b/src/modules/parcel-sync/services/epay-client.ts index 0f6da40..9d83489 100644 --- a/src/modules/parcel-sync/services/epay-client.ts +++ b/src/modules/parcel-sync/services/epay-client.ts @@ -54,11 +54,24 @@ type SessionEntry = { const globalStore = globalThis as { __epaySessionCache?: Map; + __epayCleanupTimer?: ReturnType; }; const sessionCache = globalStore.__epaySessionCache ?? new Map(); globalStore.__epaySessionCache = sessionCache; +// Periodic cleanup of expired sessions (every 5 minutes, 9-min TTL) +if (!globalStore.__epayCleanupTimer) { + globalStore.__epayCleanupTimer = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of sessionCache.entries()) { + if (now - entry.lastUsed > 9 * 60_000) { + sessionCache.delete(key); + } + } + }, 5 * 60_000); +} + const makeCacheKey = (u: string, p: string) => crypto.createHash("sha256").update(`epay:${u}:${p}`).digest("hex"); diff --git a/src/modules/parcel-sync/services/epay-queue.ts b/src/modules/parcel-sync/services/epay-queue.ts index 2390e23..f86d15a 100644 --- a/src/modules/parcel-sync/services/epay-queue.ts +++ b/src/modules/parcel-sync/services/epay-queue.ts @@ -117,27 +117,29 @@ export async function enqueueBatch( const items: QueueItem[] = []; for (const input of inputs) { - // Create DB record in "queued" status - const record = await prisma.cfExtract.create({ - data: { - nrCadastral: input.nrCadastral, - nrCF: input.nrCF ?? input.nrCadastral, - siruta: input.siruta, - judetIndex: input.judetIndex, - judetName: input.judetName, - uatId: input.uatId, - uatName: input.uatName, - gisFeatureId: input.gisFeatureId, - prodId: input.prodId ?? 14200, - status: "queued", - version: - (( - await prisma.cfExtract.aggregate({ - where: { nrCadastral: input.nrCadastral }, - _max: { version: true }, - }) - )._max.version ?? 0) + 1, - }, + // Create DB record in "queued" status — use transaction + advisory lock + // to prevent duplicate version numbers from concurrent requests + const record = await prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'cfextract:' + input.nrCadastral}))`; + const agg = await tx.cfExtract.aggregate({ + where: { nrCadastral: input.nrCadastral }, + _max: { version: true }, + }); + return tx.cfExtract.create({ + data: { + nrCadastral: input.nrCadastral, + nrCF: input.nrCF ?? input.nrCadastral, + siruta: input.siruta, + judetIndex: input.judetIndex, + judetName: input.judetName, + uatId: input.uatId, + uatName: input.uatName, + gisFeatureId: input.gisFeatureId, + prodId: input.prodId ?? 14200, + status: "queued", + version: (agg._max.version ?? 0) + 1, + }, + }); }); items.push({ extractId: record.id, input }); @@ -418,7 +420,10 @@ async function processBatch( }, ); - // Complete + // Complete — require document date from ANCPI for accurate expiry + if (!doc.dataDocument) { + console.warn(`[epay-queue] Missing dataDocument for extract ${item.extractId}, using download date`); + } const documentDate = doc.dataDocument ? new Date(doc.dataDocument) : new Date(); diff --git a/src/modules/parcel-sync/services/eterra-client.ts b/src/modules/parcel-sync/services/eterra-client.ts index beb9f9c..ba003a8 100644 --- a/src/modules/parcel-sync/services/eterra-client.ts +++ b/src/modules/parcel-sync/services/eterra-client.ts @@ -79,11 +79,24 @@ type SessionEntry = { const globalStore = globalThis as { __eterraSessionStore?: Map; + __eterraCleanupTimer?: ReturnType; }; const sessionStore = globalStore.__eterraSessionStore ?? new Map(); globalStore.__eterraSessionStore = sessionStore; +// Periodic cleanup of expired sessions (every 5 minutes, 9-min TTL) +if (!globalStore.__eterraCleanupTimer) { + globalStore.__eterraCleanupTimer = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of sessionStore.entries()) { + if (now - entry.lastUsed > 9 * 60_000) { + sessionStore.delete(key); + } + } + }, 5 * 60_000); +} + const makeCacheKey = (u: string, p: string) => crypto.createHash("sha256").update(`${u}:${p}`).digest("hex"); diff --git a/src/modules/parcel-sync/services/progress-store.ts b/src/modules/parcel-sync/services/progress-store.ts index b3c9660..33cb35a 100644 --- a/src/modules/parcel-sync/services/progress-store.ts +++ b/src/modules/parcel-sync/services/progress-store.ts @@ -16,10 +16,24 @@ export type SyncProgress = { type ProgressStore = Map; -const g = globalThis as { __parcelSyncProgressStore?: ProgressStore }; +const g = globalThis as { + __parcelSyncProgressStore?: ProgressStore; + __progressCleanupTimer?: ReturnType; +}; const store: ProgressStore = g.__parcelSyncProgressStore ?? new Map(); g.__parcelSyncProgressStore = store; +// Periodic cleanup of stale progress entries (every 30 minutes) +if (!g.__progressCleanupTimer) { + g.__progressCleanupTimer = setInterval(() => { + for (const [jobId, p] of store.entries()) { + if (p.status === "done" || p.status === "error") { + store.delete(jobId); + } + } + }, 30 * 60_000); +} + export const setProgress = (p: SyncProgress) => store.set(p.jobId, p); export const getProgress = (jobId: string) => store.get(jobId); export const clearProgress = (jobId: string) => store.delete(jobId); diff --git a/src/modules/parcel-sync/services/sync-service.ts b/src/modules/parcel-sync/services/sync-service.ts index 29438d3..0e0c919 100644 --- a/src/modules/parcel-sync/services/sync-service.ts +++ b/src/modules/parcel-sync/services/sync-service.ts @@ -237,8 +237,16 @@ export async function syncLayer( }, create: item, update: { - ...item, + siruta: item.siruta, + inspireId: item.inspireId, + cadastralRef: item.cadastralRef, + areaValue: item.areaValue, + isActive: item.isActive, + attributes: item.attributes, + geometry: item.geometry, + syncRunId: item.syncRunId, updatedAt: new Date(), + // enrichment + enrichedAt preserved — not overwritten }, }); } diff --git a/src/modules/registratura/components/close-guard-dialog.tsx b/src/modules/registratura/components/close-guard-dialog.tsx index 297af22..04ffd7f 100644 --- a/src/modules/registratura/components/close-guard-dialog.tsx +++ b/src/modules/registratura/components/close-guard-dialog.tsx @@ -9,6 +9,7 @@ import { X, FileText, } from "lucide-react"; +import { useAuth } from "@/core/auth"; import { Button } from "@/shared/components/ui/button"; import { Input } from "@/shared/components/ui/input"; import { Label } from "@/shared/components/ui/label"; @@ -66,6 +67,7 @@ export function CloseGuardDialog({ activeDeadlines, onConfirmClose, }: CloseGuardDialogProps) { + const { user } = useAuth(); const [search, setSearch] = useState(""); const [selectedEntryId, setSelectedEntryId] = useState(""); const [resolution, setResolution] = useState("finalizat"); @@ -130,7 +132,7 @@ export function CloseGuardDialog({ onConfirmClose({ resolution, reason: reason.trim(), - closedBy: "Utilizator", // TODO: replace with SSO identity + closedBy: user?.name ?? "Utilizator", closedAt: new Date().toISOString(), linkedEntryId: selectedEntryId || undefined, linkedEntryNumber: selectedEntry?.number,