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:
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user