diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1df767f..d38f06b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -23,23 +23,23 @@ model KeyValueStore { model GisSyncRule { id String @id @default(uuid()) - siruta String? /// Set = UAT-specific rule - county String? /// Set = county-wide default rule - frequency String /// "3x-daily"|"daily"|"weekly"|"monthly"|"manual" + siruta String? /// Set = UAT-specific rule + county String? /// Set = county-wide default rule + frequency String /// "3x-daily"|"daily"|"weekly"|"monthly"|"manual" syncTerenuri Boolean @default(true) syncCladiri Boolean @default(true) syncNoGeom Boolean @default(false) syncEnrich Boolean @default(false) priority Int @default(5) /// 1=highest, 10=lowest enabled Boolean @default(true) - allowedHoursStart Int? /// null = no restriction, e.g. 1 for 01:00 - allowedHoursEnd Int? /// e.g. 5 for 05:00 - allowedDays String? /// e.g. "1,2,3,4,5" for weekdays, null = all days + allowedHoursStart Int? /// null = no restriction, e.g. 1 for 01:00 + allowedHoursEnd Int? /// e.g. 5 for 05:00 + allowedDays String? /// e.g. "1,2,3,4,5" for weekdays, null = all days lastSyncAt DateTime? - lastSyncStatus String? /// "done"|"error" + lastSyncStatus String? /// "done"|"error" lastSyncError String? nextDueAt DateTime? - label String? /// Human-readable note + label String? /// Human-readable note createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -52,25 +52,25 @@ model GisSyncRule { // ─── GIS: eTerra ParcelSync ──────────────────────────────────────── model GisFeature { - id String @id @default(uuid()) - layerId String // e.g. TERENURI_ACTIVE, CLADIRI_ACTIVE + id String @id @default(uuid()) + layerId String // e.g. TERENURI_ACTIVE, CLADIRI_ACTIVE siruta String - objectId Int // eTerra OBJECTID (unique per layer); negative for no-geometry parcels (= -immovablePk) + objectId Int // eTerra OBJECTID (unique per layer); negative for no-geometry parcels (= -immovablePk) inspireId String? - cadastralRef String? // NATIONAL_CADASTRAL_REFERENCE + cadastralRef String? // NATIONAL_CADASTRAL_REFERENCE areaValue Float? - isActive Boolean @default(true) - attributes Json // all raw eTerra attributes - geometry Json? // GeoJSON geometry (Polygon/MultiPolygon) - geometrySource String? // null = normal GIS sync, "NO_GEOMETRY" = eTerra immovable without GIS geometry + isActive Boolean @default(true) + attributes Json // all raw eTerra attributes + geometry Json? // GeoJSON geometry (Polygon/MultiPolygon) + geometrySource String? // null = normal GIS sync, "NO_GEOMETRY" = eTerra immovable without GIS geometry // NOTE: native PostGIS column 'geom' is managed via SQL trigger (see prisma/postgis-setup.sql) // Prisma doesn't need to know about it — trigger auto-populates from geometry JSON - enrichment Json? // magic data: CF, owners, address, categories, etc. - enrichedAt DateTime? // when enrichment was last fetched + enrichment Json? // magic data: CF, owners, address, categories, etc. + enrichedAt DateTime? // when enrichment was last fetched syncRunId String? - projectId String? // link to project tag - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + projectId String? // link to project tag + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt syncRun GisSyncRun? @relation(fields: [syncRunId], references: [id], onDelete: SetNull) @@ -83,16 +83,16 @@ model GisFeature { } model GisSyncRun { - id String @id @default(uuid()) + id String @id @default(uuid()) siruta String uatName String? layerId String - status String @default("pending") // pending | running | done | error - totalRemote Int @default(0) - totalLocal Int @default(0) - newFeatures Int @default(0) - removedFeatures Int @default(0) - startedAt DateTime @default(now()) + status String @default("pending") // pending | running | done | error + totalRemote Int @default(0) + totalLocal Int @default(0) + newFeatures Int @default(0) + removedFeatures Int @default(0) + startedAt DateTime @default(now()) completedAt DateTime? errorMessage String? features GisFeature[] @@ -107,9 +107,9 @@ model GisUat { name String county String? workspacePk Int? - geometry Json? /// EsriGeometry { rings: number[][][] } in EPSG:3844 - areaValue Float? /// Area in sqm from LIMITE_UAT AREA_VALUE field - lastUpdatedDtm String? /// LAST_UPDATED_DTM from eTerra — for incremental sync + geometry Json? /// EsriGeometry { rings: number[][][] } in EPSG:3844 + areaValue Float? /// Area in sqm from LIMITE_UAT AREA_VALUE field + lastUpdatedDtm String? /// LAST_UPDATED_DTM from eTerra — for incremental sync updatedAt DateTime @updatedAt @@index([name]) @@ -120,9 +120,9 @@ model GisUat { model RegistrySequence { id String @id @default(uuid()) - company String // B, U, S, G (single-letter prefix) + company String // B, U, S, G (single-letter prefix) year Int - type String // SEQ (shared across directions) + type String // SEQ (shared across directions) lastSeq Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -135,7 +135,7 @@ model RegistryAudit { id String @id @default(uuid()) entryId String entryNumber String - action String // created, updated, reserved_created, reserved_claimed, late_registration, closed, deleted + action String // created, updated, reserved_created, reserved_claimed, late_registration, closed, deleted actor String actorName String? company String @@ -149,41 +149,49 @@ model RegistryAudit { // ─── ANCPI ePay: CF Extract Orders ────────────────────────────────── model CfExtract { - id String @id @default(uuid()) - orderId String? // ePay orderId (shared across batch items) - basketRowId Int? // ePay cart item ID - nrCadastral String // cadastral number - nrCF String? // CF number if different - siruta String? // UAT SIRUTA code - judetIndex Int // ePay county index (0-41) - judetName String // county display name - uatId Int // ePay UAT numeric ID - uatName String // UAT display name - prodId Int @default(14200) - solicitantId String @default("14452") - status String @default("pending") // pending|queued|cart|searching|ordering|polling|downloading|completed|failed|cancelled - epayStatus String? // raw ePay status - idDocument Int? // ePay document ID - documentName String? // ePay filename - documentDate DateTime? // when ANCPI generated - minioPath String? // MinIO object key - minioIndex Int? // file version index - creditsUsed Int @default(1) - immovableId String? // eTerra immovable ID - immovableType String? // T/C/A - measuredArea String? - legalArea String? - address String? - gisFeatureId String? // link to GisFeature - version Int @default(1) // increments on re-order - expiresAt DateTime? // 30 days after documentDate - supersededById String? // newer version id - requestedBy String? - errorMessage String? - pollAttempts Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - completedAt DateTime? + id String @id @default(uuid()) + orderId String? // ePay orderId (shared across batch items) + basketRowId Int? // ePay cart item ID + nrCadastral String // cadastral number + nrCF String? // CF number if different + siruta String? // UAT SIRUTA code + judetIndex Int // ePay county index (0-41) + judetName String // county display name + uatId Int // ePay UAT numeric ID + uatName String // UAT display name + prodId Int @default(14200) + solicitantId String @default("14452") + status String @default("pending") // pending|queued|cart|searching|ordering|polling|downloading|completed|failed|cancelled + epayStatus String? // raw ePay status + idDocument Int? // ePay document ID + documentName String? // ePay filename + documentDate DateTime? // when ANCPI generated + minioPath String? // MinIO object key + minioIndex Int? // file version index + creditsUsed Int @default(1) + immovableId String? // eTerra immovable ID + immovableType String? // T/C/A + measuredArea String? + legalArea String? + address String? + gisFeatureId String? // link to GisFeature + version Int @default(1) // increments on re-order + expiresAt DateTime? // 30 days after documentDate + supersededById String? // newer version id + requestedBy String? + errorMessage String? + pollAttempts Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + completedAt DateTime? + + // DB columns historically added by hand (not via Prisma migrate). + // Surfaced in the schema so callers can set/read them without raw + // SQL. See feedback_cfextract_schema_drift.md (2026-05-20). + userId String? // Authentik sub of the orderer + type String? @default("epay") // 'epay' | 'admin' + pdfData Bytes? // legacy inline PDF storage (most rows use minioPath instead) + adminOrderedBy String? // admin user who placed the order on someone's behalf @@index([nrCadastral]) @@index([status]) @@ -191,4 +199,6 @@ model CfExtract { @@index([gisFeatureId]) @@index([createdAt]) @@index([nrCadastral, version]) + @@index([userId]) + @@index([userId, nrCadastral]) } diff --git a/src/app/api/ancpi/order/route.ts b/src/app/api/ancpi/order/route.ts index 77b6ae2..326f226 100644 --- a/src/app/api/ancpi/order/route.ts +++ b/src/app/api/ancpi/order/route.ts @@ -5,6 +5,7 @@ import { enqueueBatch, } from "@/modules/parcel-sync/services/epay-queue"; import type { CfExtractCreateInput } from "@/modules/parcel-sync/services/epay-types"; +import { getAuthSession } from "@/core/auth/require-auth"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -95,27 +96,40 @@ export async function POST(req: Request) { } } + // Stamp the orderer's session id on each enqueued row so CfExtract + // carries ownership info (was NULL before — see + // feedback_cfextract_schema_drift.md). Falls back to undefined when + // the route is hit without a session (dev tools / cron). + const session = await getAuthSession(); + const userId = + ((session?.user as { id?: string } | undefined)?.id || + session?.user?.email) ?? undefined; + const stampedParcels: CfExtractCreateInput[] = parcels.map((p) => ({ + ...p, + userId: p.userId ?? userId, + })); + let responseBody: { orders: Array<{ id: string; nrCadastral: string; status: string }>; }; - if (parcels.length === 1) { - const id = await enqueueOrder(parcels[0]!); + if (stampedParcels.length === 1) { + const id = await enqueueOrder(stampedParcels[0]!); responseBody = { orders: [ { id, - nrCadastral: parcels[0]!.nrCadastral, + nrCadastral: stampedParcels[0]!.nrCadastral, status: "queued", }, ], }; } else { - const ids = await enqueueBatch(parcels); + const ids = await enqueueBatch(stampedParcels); responseBody = { orders: ids.map((id, i) => ({ id, - nrCadastral: parcels[i]?.nrCadastral ?? "", + nrCadastral: stampedParcels[i]?.nrCadastral ?? "", status: "queued", })), }; diff --git a/src/modules/parcel-sync/services/epay-queue.ts b/src/modules/parcel-sync/services/epay-queue.ts index f86d15a..92156bc 100644 --- a/src/modules/parcel-sync/services/epay-queue.ts +++ b/src/modules/parcel-sync/services/epay-queue.ts @@ -138,6 +138,12 @@ export async function enqueueBatch( prodId: input.prodId ?? 14200, status: "queued", version: (agg._max.version ?? 0) + 1, + // userId: Authentik sub of the orderer (propagated from + // /api/ancpi/order's session). Falls back to undefined for + // legacy callers that don't pass it — DB allows NULL after + // the 2026-05-20 schema patch. + userId: input.userId, + type: "epay", }, }); }); diff --git a/src/modules/parcel-sync/services/epay-types.ts b/src/modules/parcel-sync/services/epay-types.ts index 6f93780..0b2104b 100644 --- a/src/modules/parcel-sync/services/epay-types.ts +++ b/src/modules/parcel-sync/services/epay-types.ts @@ -95,6 +95,10 @@ export type CfExtractCreateInput = { uatName: string; gisFeatureId?: string; prodId?: number; + /** Authentik sub (or any stable session id) of the user placing the + * order. Persisted on `CfExtract.userId` so we can audit who ordered + * what + scope RLS later. Optional for legacy callers. */ + userId?: string; }; export type OrderMetadata = {