feat(faza-c.2): gate legacy GisFeature writes under USE_GIS_AC

Adds gateLegacyGisWrite() helper that returns 410 when the caller is on
the api.gis.ac path (global USE_GIS_AC=1 or per-user GIS_AC_PILOT_USERS).
Wired into 13 routes covering every entry point that touches Gis*
tables on architools_postgres — directly or via parcel-sync services.

Why: yesterday 4 GisFeature rows were updated on architools_postgres
even though the scheduler is officially disabled. Root cause: pilot
user opened the legacy /geoportal UI in a stale tab and clicked
parcels; POST /api/geoportal/enrich wrote directly to the local DB.
Without a write gate, Faza H (pg_dump + REVOKE + DROP) is unsafe —
any stale tab in any user's browser can still trip writes between
freeze and DROP.

Gated routes (writes only — reads stay open for rollback ergonomics):
- /api/geoportal/enrich (POST) — the writer of the 4 rows
- /api/eterra/sync-rules (POST), /api/eterra/sync-rules/[id] (PATCH+DELETE)
- /api/eterra/sync-rules/bulk (POST)
- /api/eterra/uats (POST+PATCH)
- /api/eterra/sync (POST), /api/eterra/sync-county (POST)
- /api/eterra/sync-background (POST), /api/eterra/sync-all-counties (POST)
- /api/eterra/auto-refresh (POST), /api/eterra/refresh-all (POST)
- /api/eterra/export-layer-gpkg (POST), /api/eterra/export-bundle (POST)
  (last two trigger syncLayer write-first-then-export)

Read-only routes intentionally NOT gated: sync-status, no-geom-scan
(scanNoGeometryParcels is read-only), export-local, db-summary,
counties, search, features (GET), stats, uat-dashboard, sync-rules
(GET), sync-rules/scheduler.

Operations: after redeploy, flip USE_GIS_AC=1 in Infisical /architools
prod env and restart container. Then monitor docker logs for ~30 min:
grep "deprecated" + "/api/geoportal/enrich|/api/eterra/sync*" lines
indicate stale-tab clients that need a refresh. pg_stat_user_tables
write count on GisFeature should hit 0 within one hour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude VM
2026-05-19 11:00:16 +03:00
parent 9847b4a070
commit b957de77b9
14 changed files with 91 additions and 0 deletions
+3
View File
@@ -6,6 +6,7 @@ import {
isFresh,
} from "@/modules/parcel-sync/services/enrich-service";
import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-health";
import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -38,6 +39,8 @@ type UatRefreshResult = {
* ?includeEnrichment=true — re-enrich UATs with partial enrichment
*/
export async function POST(request: Request) {
const gate = await gateLegacyGisWrite("/api/eterra/auto-refresh");
if (gate) return gate;
// ── Auth ──
const secret = process.env.NOTIFICATION_CRON_SECRET;
if (!secret) {
@@ -33,6 +33,7 @@ import {
} from "@/modules/parcel-sync/services/session-store";
import { syncNoGeometryParcels } from "@/modules/parcel-sync/services/no-geom-sync";
import type { GeoJsonFeature } from "@/modules/parcel-sync/services/esri-geojson";
import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -87,6 +88,8 @@ const csvEscape = (val: unknown) => {
};
export async function POST(req: Request) {
const gate = await gateLegacyGisWrite("/api/eterra/export-bundle");
if (gate) return gate;
let jobId: string | undefined;
let message: string | undefined;
let phase = "Inițializare";
@@ -28,6 +28,7 @@ import {
unregisterJob,
} from "@/modules/parcel-sync/services/session-store";
import type { GeoJsonFeature } from "@/modules/parcel-sync/services/esri-geojson";
import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -68,6 +69,8 @@ const scheduleClear = (jobId?: string) => {
};
export async function POST(req: Request) {
const gate = await gateLegacyGisWrite("/api/eterra/export-layer-gpkg");
if (gate) return gate;
let jobId: string | undefined;
let message: string | undefined;
let phase = "Inițializare";
+3
View File
@@ -18,6 +18,7 @@ import { syncLayer } from "@/modules/parcel-sync/services/sync-service";
import { enrichFeatures } from "@/modules/parcel-sync/services/enrich-service";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-health";
import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -25,6 +26,8 @@ export const dynamic = "force-dynamic";
const prisma = new PrismaClient();
export async function POST() {
const gate = await gateLegacyGisWrite("/api/eterra/refresh-all");
if (gate) return gate;
const username = process.env.ETERRA_USERNAME ?? "";
const password = process.env.ETERRA_PASSWORD ?? "";
if (!username || !password) {
@@ -21,6 +21,7 @@ import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-heal
import { createAppNotification } from "@/core/notifications/app-notifications";
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
import { firePmtilesRebuild } from "@/modules/parcel-sync/services/pmtiles-webhook";
import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -32,6 +33,8 @@ const g = globalThis as {
};
export async function POST() {
const gate = await gateLegacyGisWrite("/api/eterra/sync-all-counties");
if (gate) return gate;
const session = getSessionCredentials();
const username = String(session?.username || process.env.ETERRA_USERNAME || "").trim();
const password = String(session?.password || process.env.ETERRA_PASSWORD || "").trim();
@@ -30,6 +30,7 @@ import {
} from "@/modules/parcel-sync/services/enrich-service";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import { syncNoGeometryParcels } from "@/modules/parcel-sync/services/no-geom-sync";
import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -44,6 +45,8 @@ type Body = {
};
export async function POST(req: Request) {
const gate = await gateLegacyGisWrite("/api/eterra/sync-background");
if (gate) return gate;
try {
const body = (await req.json()) as Body;
const session = getSessionCredentials();
+3
View File
@@ -22,6 +22,7 @@ import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-heal
import { createAppNotification } from "@/core/notifications/app-notifications";
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
import { firePmtilesRebuild } from "@/modules/parcel-sync/services/pmtiles-webhook";
import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -30,6 +31,8 @@ export const dynamic = "force-dynamic";
const g = globalThis as { __countySyncRunning?: string; __allCountiesSyncRunning?: boolean };
export async function POST(req: Request) {
const gate = await gateLegacyGisWrite("/api/eterra/sync-county");
if (gate) return gate;
let body: { county?: string };
try {
body = (await req.json()) as { county?: string };
@@ -5,6 +5,7 @@
import { prisma } from "@/core/storage/prisma";
import { NextResponse } from "next/server";
import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -27,6 +28,8 @@ export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const gate = await gateLegacyGisWrite("/api/eterra/sync-rules/[id]");
if (gate) return gate;
const { id } = await params;
try {
@@ -83,6 +86,8 @@ export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const gate = await gateLegacyGisWrite("/api/eterra/sync-rules/[id]");
if (gate) return gate;
const { id } = await params;
try {
@@ -9,6 +9,7 @@
import { prisma } from "@/core/storage/prisma";
import { NextResponse } from "next/server";
import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -37,6 +38,8 @@ type BulkBody = {
};
export async function POST(req: Request) {
const gate = await gateLegacyGisWrite("/api/eterra/sync-rules/bulk");
if (gate) return gate;
try {
const body = (await req.json()) as BulkBody;
+3
View File
@@ -5,6 +5,7 @@
import { prisma } from "@/core/storage/prisma";
import { NextResponse } from "next/server";
import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -84,6 +85,8 @@ export async function GET() {
}
export async function POST(req: Request) {
const gate = await gateLegacyGisWrite("/api/eterra/sync-rules");
if (gate) return gate;
try {
const body = (await req.json()) as {
siruta?: string;
+3
View File
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { syncLayer } from "@/modules/parcel-sync/services/sync-service";
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -17,6 +18,8 @@ type Body = {
};
export async function POST(req: Request) {
const gate = await gateLegacyGisWrite("/api/eterra/sync");
if (gate) return gate;
try {
const body = (await req.json()) as Body;
const session = getSessionCredentials();
+5
View File
@@ -4,6 +4,7 @@ import { readFile } from "fs/promises";
import { join } from "path";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -212,6 +213,8 @@ export async function GET() {
/* ------------------------------------------------------------------ */
export async function POST() {
const gate = await gateLegacyGisWrite("/api/eterra/uats");
if (gate) return gate;
try {
// Read uat.json from public/ directory
@@ -288,6 +291,8 @@ export async function POST() {
/* ------------------------------------------------------------------ */
export async function PATCH() {
const gate = await gateLegacyGisWrite("/api/eterra/uats");
if (gate) return gate;
try {
// 1. Get eTerra credentials from session
const session = getSessionCredentials();
+3
View File
@@ -9,11 +9,14 @@
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
import { headers } from "next/headers";
import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(req: Request) {
const gate = await gateLegacyGisWrite("/api/geoportal/enrich");
if (gate) return gate;
try {
const body = (await req.json()) as { featureId?: string; siruta?: string; objectId?: number };
+48
View File
@@ -0,0 +1,48 @@
// Faza C.2 — hard-disable legacy writes to architools_postgres Gis* tables
// when the api.gis.ac path is enabled for the caller (per-user pilot or global).
//
// Called at the top of every route handler that triggers a write through
// `prisma.gisFeature.*`, `prisma.gisUat.*`, `prisma.gisSyncRun.*`,
// `prisma.gisSyncRule.*` — directly or via parcel-sync services. Read-only
// handlers (sync-status, export-local, *_GET) are intentionally NOT gated:
// freezing reads breaks rollback ergonomics with no benefit, since Faza H
// only needs writes to stop.
//
// Returns a NextResponse(410) when the legacy path is blocked. Returns null
// when the caller may continue. Background/cron callers (no session) hit the
// `USE_GIS_AC === "1"` fast-path; user-initiated requests fall to the
// pilot-list check.
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/core/auth/auth-options";
import { useGisAcFlag } from "./use-gis-ac";
export async function gateLegacyGisWrite(
endpoint: string,
): Promise<NextResponse | null> {
if (process.env.USE_GIS_AC === "1") {
return NextResponse.json(
{
error: "deprecated",
endpoint,
message:
"Local GisFeature writes are disabled. Use the api.gis.ac equivalent.",
},
{ status: 410 },
);
}
const session = await getServerSession(authOptions).catch(() => null);
if (useGisAcFlag(session?.user?.email)) {
return NextResponse.json(
{
error: "deprecated",
endpoint,
message:
"This user is on the api.gis.ac path; refresh the UI to use the new flow.",
},
{ status: 410 },
);
}
return null;
}