Initial Commit : Network Map
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
Postgres + pg_cron scheduled shift reminders
|
||||
|
||||
1) Run migrations
|
||||
- Apply `supabase/migrations/20260318_add_scheduled_notifications.sql` (and other pending migrations) to your Supabase DB.
|
||||
|
||||
2) Enable pg_cron (requires Supabase project admin)
|
||||
- If your Supabase tier supports it, enable the `pg_cron` extension.
|
||||
- Example (run as a privileged role):
|
||||
CREATE EXTENSION IF NOT EXISTS pg_cron;
|
||||
SELECT cron.schedule('shift_reminders_every_min', '*/1 * * * *', $$SELECT public.enqueue_due_shift_notifications();$$);
|
||||
|
||||
3) Deploy processor Edge Function
|
||||
- Add `supabase/functions/process_scheduled_notifications/` to your Supabase functions and deploy with the following required env vars:
|
||||
- `SUPABASE_URL`
|
||||
- `SUPABASE_SERVICE_ROLE_KEY`
|
||||
- `SEND_FCM_URL` (the HTTP URL for your existing `send_fcm` function, e.g., https://<project>.functions.supabase.co/send_fcm)
|
||||
- Optional: `PROCESSOR_BATCH_SIZE` (default 50)
|
||||
|
||||
- You can deploy via `supabase functions deploy process_scheduled_notifications` or using your CI.
|
||||
|
||||
4) Scheduling / triggering processor
|
||||
- Option A (recommended): Use `pg_cron` to only enqueue rows, and configure a small interval GitHub Actions or Cloud Scheduler to call the Edge Function endpoint (POST) every minute. This keeps sending out of the DB.
|
||||
- Option B: Use `pg_cron` to call the Edge Function HTTP endpoint directly if `pg_http` is available (not recommended unless approved).
|
||||
|
||||
5) Verification
|
||||
- Insert a test `duty_schedules` record 15 minutes ahead and run `SELECT public.enqueue_due_shift_notifications();` manually — confirm `scheduled_notifications` row appears.
|
||||
- Call the Edge Function (`supabase functions invoke process_scheduled_notifications --project <id>`) or POST to its URL — confirm `send_fcm` receives payload and `scheduled_notifications` row becomes processed.
|
||||
|
||||
6) Monitoring
|
||||
- Watch `cron.job_run_details` for cron runs and `scheduled_notifications` rows with `retry_count` > 3.
|
||||
- Inspect `notification_pushes` table to ensure deduplication works.
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"tasks": {
|
||||
"start": "deno run --allow-net --allow-env --allow-read index.ts"
|
||||
},
|
||||
"imports": {}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
// extract_topology — Supabase Edge Function
|
||||
// Reads a network-imports file from storage, sends it to Gemini with a strict
|
||||
// JSON-schema prompt, and writes the parsed draft back to network_imports.
|
||||
//
|
||||
// Auth: caller must be admin or it_staff. Mirrors teams_crud auth pattern.
|
||||
// Gemini model selection: 2.5 Flash by default; 2.5 Pro for PDFs >5 pages.
|
||||
//
|
||||
// Env vars required:
|
||||
// SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY, GEMINI_API_KEY
|
||||
|
||||
import { serve } from "https://deno.land/std@0.203.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
const CORS_HEADERS: Record<string, string> = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers":
|
||||
"Content-Type, Authorization, apikey, x-client-info, x-requested-with, Origin, Accept",
|
||||
"Access-Control-Max-Age": "3600",
|
||||
};
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
});
|
||||
|
||||
// Strict response schema we send to Gemini. Items get confidence scores so the
|
||||
// review UI can rank "definitely a switch" above "might be a switch".
|
||||
const RESPONSE_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
sites: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
address: { type: "string" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
devices: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
kind: {
|
||||
type: "string",
|
||||
enum: ["router", "switch", "ap", "firewall", "server", "endpoint", "patch_panel", "other"],
|
||||
},
|
||||
vendor: { type: "string" },
|
||||
model: { type: "string" },
|
||||
mgmt_ip: { type: "string" },
|
||||
site_name: { type: "string" },
|
||||
location_label: { type: "string" },
|
||||
confidence: { type: "string", enum: ["high", "medium", "low"] },
|
||||
},
|
||||
required: ["name", "kind"],
|
||||
},
|
||||
},
|
||||
ports: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
device_name: { type: "string" },
|
||||
port_number: { type: "string" },
|
||||
port_kind: {
|
||||
type: "string",
|
||||
enum: ["rj45", "sfp", "sfp_plus", "console", "power", "wireless", "virtual", "other"],
|
||||
},
|
||||
access_vlan: { type: "integer" },
|
||||
is_trunk: { type: "boolean" },
|
||||
},
|
||||
required: ["device_name", "port_number"],
|
||||
},
|
||||
},
|
||||
links: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
device_a: { type: "string" },
|
||||
port_a: { type: "string" },
|
||||
device_b: { type: "string" },
|
||||
port_b: { type: "string" },
|
||||
link_kind: {
|
||||
type: "string",
|
||||
enum: ["copper", "fiber", "wireless", "virtual", "unknown"],
|
||||
},
|
||||
confidence: { type: "string", enum: ["high", "medium", "low"] },
|
||||
},
|
||||
required: ["device_a", "device_b"],
|
||||
},
|
||||
},
|
||||
vlans: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
vlan_id: { type: "integer" },
|
||||
name: { type: "string" },
|
||||
description: { type: "string" },
|
||||
},
|
||||
required: ["vlan_id", "name"],
|
||||
},
|
||||
},
|
||||
notes: { type: "string" },
|
||||
},
|
||||
};
|
||||
|
||||
const EXTRACTION_PROMPT = `You are a network-documentation reader. Look at the attached file (which may be a network diagram in PDF, an image of a diagram, a photo of a whiteboard, a Visio export, or a spreadsheet/notes) and extract every network device, port, link, VLAN, and site you can identify.
|
||||
|
||||
Rules:
|
||||
- Output ONLY structured JSON conforming to the provided schema. No prose.
|
||||
- For devices, classify kind precisely (switch / router / ap / etc.). If you genuinely cannot tell, use "other".
|
||||
- For ports and links, give the exact labels as they appear ("Gi0/1", "Port 24", "uplink", etc.) — do not normalize.
|
||||
- For each device and each link, attach a "confidence": "high" if the document clearly states it, "medium" if it's reasonably implied, "low" if it's a guess.
|
||||
- If you cannot extract anything useful, return all arrays as empty and put a brief explanation in "notes".
|
||||
- Do not invent items. Better to return less with high confidence than more with low.
|
||||
- VLANs: only include if a VLAN ID number is visible.
|
||||
- Sites: only include if a site/building name is clearly indicated.`;
|
||||
|
||||
interface ImportRow {
|
||||
id: string;
|
||||
storage_path: string;
|
||||
file_kind: string;
|
||||
status: string;
|
||||
created_by: string;
|
||||
}
|
||||
|
||||
function pickGeminiModel(fileKind: string, byteLength: number): string {
|
||||
// Cheap default; escalate to Pro for big PDFs where layout reasoning matters.
|
||||
if (fileKind === "pdf" && byteLength > 2 * 1024 * 1024) {
|
||||
return "gemini-2.5-pro";
|
||||
}
|
||||
return "gemini-2.5-flash";
|
||||
}
|
||||
|
||||
function mimeForFileKind(fileKind: string): string {
|
||||
switch (fileKind) {
|
||||
case "pdf":
|
||||
return "application/pdf";
|
||||
case "image":
|
||||
// Default to PNG; Gemini accepts most image MIMEs and will sniff if needed.
|
||||
return "image/png";
|
||||
case "vsdx":
|
||||
return "application/vnd.ms-visio.drawing";
|
||||
case "csv":
|
||||
return "text/csv";
|
||||
case "xlsx":
|
||||
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
case "docx":
|
||||
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
default:
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
// Deno has no btoa for binary; build base64 in chunks to avoid stack overflow.
|
||||
let binary = "";
|
||||
const chunkSize = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
const slice = bytes.subarray(i, i + chunkSize);
|
||||
binary += String.fromCharCode.apply(null, Array.from(slice));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
||||
}
|
||||
if (req.method !== "POST") {
|
||||
return jsonResponse({ error: "Method not allowed" }, 405);
|
||||
}
|
||||
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL") ?? "";
|
||||
const anonKey = Deno.env.get("SUPABASE_ANON_KEY") ?? "";
|
||||
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
|
||||
const geminiKey = Deno.env.get("GEMINI_API_KEY") ?? "";
|
||||
if (!supabaseUrl || !anonKey || !serviceKey) {
|
||||
return jsonResponse({ error: "Missing Supabase env config" }, 500);
|
||||
}
|
||||
if (!geminiKey) {
|
||||
return jsonResponse({ error: "GEMINI_API_KEY not configured" }, 500);
|
||||
}
|
||||
|
||||
// Auth caller
|
||||
const authHeader = req.headers.get("Authorization") ?? "";
|
||||
const token = authHeader.replace("Bearer ", "").trim();
|
||||
if (!token) return jsonResponse({ error: "Missing access token" }, 401);
|
||||
|
||||
const authClient = createClient(supabaseUrl, anonKey, {
|
||||
global: { headers: { Authorization: `Bearer ${token}` } },
|
||||
});
|
||||
const { data: authData, error: authError } = await authClient.auth.getUser();
|
||||
if (authError || !authData?.user) return jsonResponse({ error: "Unauthorized" }, 401);
|
||||
|
||||
const adminClient = createClient(supabaseUrl, serviceKey);
|
||||
|
||||
// Caller must be admin or it_staff
|
||||
const { data: profile, error: profileError } = await adminClient
|
||||
.from("profiles")
|
||||
.select("role")
|
||||
.eq("id", authData.user.id)
|
||||
.maybeSingle();
|
||||
const role = (profile?.role ?? "").toString().toLowerCase();
|
||||
if (profileError || (role !== "admin" && role !== "it_staff")) {
|
||||
return jsonResponse({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
// Body: { import_id: string }
|
||||
let body: { import_id?: string } = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch (_e) {
|
||||
return jsonResponse({ error: "Invalid JSON body" }, 400);
|
||||
}
|
||||
const importId = body.import_id;
|
||||
if (!importId || typeof importId !== "string") {
|
||||
return jsonResponse({ error: "Missing import_id" }, 400);
|
||||
}
|
||||
|
||||
// Load the import row
|
||||
const { data: imp, error: impErr } = await adminClient
|
||||
.from("network_imports")
|
||||
.select("id, storage_path, file_kind, status, created_by")
|
||||
.eq("id", importId)
|
||||
.maybeSingle<ImportRow>();
|
||||
if (impErr || !imp) {
|
||||
return jsonResponse({ error: "Import not found" }, 404);
|
||||
}
|
||||
if (imp.status === "applied" || imp.status === "discarded") {
|
||||
return jsonResponse({ error: `Import already ${imp.status}` }, 409);
|
||||
}
|
||||
|
||||
// Mark extracting
|
||||
await adminClient
|
||||
.from("network_imports")
|
||||
.update({ status: "extracting", error_message: null })
|
||||
.eq("id", importId);
|
||||
|
||||
try {
|
||||
// Download from storage
|
||||
const { data: fileData, error: dlErr } = await adminClient
|
||||
.storage
|
||||
.from("network-imports")
|
||||
.download(imp.storage_path);
|
||||
if (dlErr || !fileData) {
|
||||
throw new Error(dlErr?.message ?? "Failed to download import file");
|
||||
}
|
||||
|
||||
const arrayBuf = await fileData.arrayBuffer();
|
||||
const bytes = new Uint8Array(arrayBuf);
|
||||
const base64 = bytesToBase64(bytes);
|
||||
const mime = mimeForFileKind(imp.file_kind);
|
||||
const model = pickGeminiModel(imp.file_kind, bytes.length);
|
||||
|
||||
// Call Gemini
|
||||
const geminiUrl =
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${geminiKey}`;
|
||||
const geminiBody = {
|
||||
contents: [
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{ text: EXTRACTION_PROMPT },
|
||||
{ inlineData: { mimeType: mime, data: base64 } },
|
||||
],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: RESPONSE_SCHEMA,
|
||||
temperature: 0.1,
|
||||
},
|
||||
};
|
||||
|
||||
const geminiRes = await fetch(geminiUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(geminiBody),
|
||||
});
|
||||
|
||||
if (!geminiRes.ok) {
|
||||
const errBody = await geminiRes.text();
|
||||
throw new Error(`Gemini ${geminiRes.status}: ${errBody.slice(0, 500)}`);
|
||||
}
|
||||
|
||||
const geminiJson = await geminiRes.json();
|
||||
const textOut: string =
|
||||
geminiJson?.candidates?.[0]?.content?.parts?.[0]?.text ?? "";
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(textOut);
|
||||
} catch (_e) {
|
||||
throw new Error("Gemini returned non-JSON output");
|
||||
}
|
||||
|
||||
// Minimum-quality gate: must have at least one device, or status=failed.
|
||||
const deviceCount = Array.isArray((parsed as { devices?: unknown[] })?.devices)
|
||||
? (parsed as { devices: unknown[] }).devices.length
|
||||
: 0;
|
||||
|
||||
if (deviceCount === 0) {
|
||||
await adminClient
|
||||
.from("network_imports")
|
||||
.update({
|
||||
status: "failed",
|
||||
ai_result: parsed as Record<string, unknown>,
|
||||
error_message: "No devices extracted from document. Start from blank canvas.",
|
||||
})
|
||||
.eq("id", importId);
|
||||
return jsonResponse(
|
||||
{ status: "failed", reason: "no_devices", ai_result: parsed },
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
// Store + mark for review
|
||||
await adminClient
|
||||
.from("network_imports")
|
||||
.update({
|
||||
status: "review",
|
||||
ai_result: parsed as Record<string, unknown>,
|
||||
error_message: null,
|
||||
})
|
||||
.eq("id", importId);
|
||||
|
||||
return jsonResponse({ status: "review", ai_result: parsed });
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message ?? String(err);
|
||||
await adminClient
|
||||
.from("network_imports")
|
||||
.update({ status: "failed", error_message: msg })
|
||||
.eq("id", importId);
|
||||
return jsonResponse({ error: msg }, 500);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
-- Network Infrastructure Map — V1 schema.
|
||||
-- Adds 7 tables (sites, locations, devices, ports, links, vlans, imports)
|
||||
-- plus a private Storage bucket for uploaded source documents.
|
||||
-- RLS: read for admin/it_staff/dispatcher/programmer; write for admin/it_staff only.
|
||||
-- See plan: C:\Users\marcr\.claude\plans\i-m-thinking-of-nested-lagoon.md
|
||||
|
||||
-- =============================================================================
|
||||
-- TABLES
|
||||
-- =============================================================================
|
||||
|
||||
-- Sites: top-level physical locations (campus, building treated standalone)
|
||||
CREATE TABLE IF NOT EXISTS public.network_sites (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
address text,
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_by uuid REFERENCES public.profiles(id)
|
||||
);
|
||||
|
||||
-- Locations: nested physical hierarchy within a site
|
||||
CREATE TABLE IF NOT EXISTS public.network_locations (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
site_id uuid NOT NULL REFERENCES public.network_sites(id) ON DELETE CASCADE,
|
||||
parent_id uuid REFERENCES public.network_locations(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
kind text NOT NULL CHECK (kind IN ('building','floor','room','rack','wall_jack','other')),
|
||||
position_label text,
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_network_locations_site ON public.network_locations(site_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_network_locations_parent ON public.network_locations(parent_id);
|
||||
|
||||
-- Devices: switches, routers, APs, endpoints, patch panels
|
||||
CREATE TABLE IF NOT EXISTS public.network_devices (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
kind text NOT NULL CHECK (kind IN
|
||||
('router','switch','ap','firewall','server','endpoint','patch_panel','other')),
|
||||
role text CHECK (role IN ('core','distribution','access','edge','endpoint')),
|
||||
vendor text,
|
||||
model text,
|
||||
serial text,
|
||||
mgmt_ip inet,
|
||||
mac macaddr,
|
||||
location_id uuid REFERENCES public.network_locations(id) ON DELETE SET NULL,
|
||||
import_source text NOT NULL DEFAULT 'manual'
|
||||
CHECK (import_source IN ('manual','ai_import','agent')),
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_network_devices_location ON public.network_devices(location_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_network_devices_role ON public.network_devices(role);
|
||||
CREATE INDEX IF NOT EXISTS idx_network_devices_kind ON public.network_devices(kind);
|
||||
|
||||
-- Ports: physical/logical ports on a device
|
||||
CREATE TABLE IF NOT EXISTS public.network_ports (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
device_id uuid NOT NULL REFERENCES public.network_devices(id) ON DELETE CASCADE,
|
||||
port_number text NOT NULL,
|
||||
port_kind text CHECK (port_kind IN ('rj45','sfp','sfp_plus','console','power','wireless','virtual','other')),
|
||||
speed_mbps integer,
|
||||
access_vlan integer,
|
||||
is_trunk boolean NOT NULL DEFAULT false,
|
||||
trunk_vlans integer[],
|
||||
notes text,
|
||||
UNIQUE (device_id, port_number)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_network_ports_device ON public.network_ports(device_id);
|
||||
|
||||
-- Links: port-to-port edges. Canonical ordering (port_a < port_b) prevents A↔B/B↔A dupes.
|
||||
CREATE TABLE IF NOT EXISTS public.network_links (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
port_a uuid NOT NULL REFERENCES public.network_ports(id) ON DELETE CASCADE,
|
||||
port_b uuid NOT NULL REFERENCES public.network_ports(id) ON DELETE CASCADE,
|
||||
link_kind text CHECK (link_kind IN ('copper','fiber','wireless','virtual','unknown')),
|
||||
cable_label text,
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK (port_a < port_b),
|
||||
UNIQUE (port_a, port_b)
|
||||
);
|
||||
|
||||
-- VLANs (org-scoped, named)
|
||||
CREATE TABLE IF NOT EXISTS public.network_vlans (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
vlan_id integer NOT NULL UNIQUE,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
color text
|
||||
);
|
||||
|
||||
-- Import sessions: each upload + extraction state + raw AI result for audit/replay
|
||||
CREATE TABLE IF NOT EXISTS public.network_imports (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
storage_path text NOT NULL,
|
||||
file_kind text NOT NULL
|
||||
CHECK (file_kind IN ('pdf','image','vsdx','csv','xlsx','docx','other')),
|
||||
status text NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending','extracting','review','applied','discarded','failed')),
|
||||
ai_result jsonb,
|
||||
error_message text,
|
||||
applied_device_ids uuid[],
|
||||
applied_link_ids uuid[],
|
||||
applied_at timestamptz,
|
||||
created_by uuid NOT NULL REFERENCES public.profiles(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_network_imports_status ON public.network_imports(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_network_imports_created_by ON public.network_imports(created_by);
|
||||
|
||||
-- =============================================================================
|
||||
-- updated_at TRIGGERS
|
||||
-- =============================================================================
|
||||
-- Reuses common pattern from prior Tasq migrations.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.network_map_set_updated_at()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_network_sites_updated_at ON public.network_sites;
|
||||
CREATE TRIGGER trg_network_sites_updated_at
|
||||
BEFORE UPDATE ON public.network_sites
|
||||
FOR EACH ROW EXECUTE FUNCTION public.network_map_set_updated_at();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_network_devices_updated_at ON public.network_devices;
|
||||
CREATE TRIGGER trg_network_devices_updated_at
|
||||
BEFORE UPDATE ON public.network_devices
|
||||
FOR EACH ROW EXECUTE FUNCTION public.network_map_set_updated_at();
|
||||
|
||||
-- =============================================================================
|
||||
-- ROW LEVEL SECURITY
|
||||
-- =============================================================================
|
||||
-- Pattern matches supabase/migrations/20260223090000_services_read_only_for_standard_it_dispatcher.sql
|
||||
-- READ: admin + it_staff + dispatcher + programmer
|
||||
-- WRITE: admin + it_staff
|
||||
|
||||
ALTER TABLE public.network_sites ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.network_locations ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.network_devices ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.network_ports ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.network_links ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.network_vlans ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.network_imports ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- ----- network_sites -----
|
||||
DROP POLICY IF EXISTS "Network sites: select" ON public.network_sites;
|
||||
CREATE POLICY "Network sites: select" ON public.network_sites
|
||||
FOR SELECT USING (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid()
|
||||
AND p.role IN ('admin','it_staff','dispatcher','programmer'))
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "Network sites: write" ON public.network_sites;
|
||||
CREATE POLICY "Network sites: write" ON public.network_sites
|
||||
FOR ALL USING (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
) WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
);
|
||||
|
||||
-- ----- network_locations -----
|
||||
DROP POLICY IF EXISTS "Network locations: select" ON public.network_locations;
|
||||
CREATE POLICY "Network locations: select" ON public.network_locations
|
||||
FOR SELECT USING (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid()
|
||||
AND p.role IN ('admin','it_staff','dispatcher','programmer'))
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "Network locations: write" ON public.network_locations;
|
||||
CREATE POLICY "Network locations: write" ON public.network_locations
|
||||
FOR ALL USING (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
) WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
);
|
||||
|
||||
-- ----- network_devices -----
|
||||
DROP POLICY IF EXISTS "Network devices: select" ON public.network_devices;
|
||||
CREATE POLICY "Network devices: select" ON public.network_devices
|
||||
FOR SELECT USING (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid()
|
||||
AND p.role IN ('admin','it_staff','dispatcher','programmer'))
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "Network devices: write" ON public.network_devices;
|
||||
CREATE POLICY "Network devices: write" ON public.network_devices
|
||||
FOR ALL USING (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
) WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
);
|
||||
|
||||
-- ----- network_ports -----
|
||||
DROP POLICY IF EXISTS "Network ports: select" ON public.network_ports;
|
||||
CREATE POLICY "Network ports: select" ON public.network_ports
|
||||
FOR SELECT USING (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid()
|
||||
AND p.role IN ('admin','it_staff','dispatcher','programmer'))
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "Network ports: write" ON public.network_ports;
|
||||
CREATE POLICY "Network ports: write" ON public.network_ports
|
||||
FOR ALL USING (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
) WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
);
|
||||
|
||||
-- ----- network_links -----
|
||||
DROP POLICY IF EXISTS "Network links: select" ON public.network_links;
|
||||
CREATE POLICY "Network links: select" ON public.network_links
|
||||
FOR SELECT USING (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid()
|
||||
AND p.role IN ('admin','it_staff','dispatcher','programmer'))
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "Network links: write" ON public.network_links;
|
||||
CREATE POLICY "Network links: write" ON public.network_links
|
||||
FOR ALL USING (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
) WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
);
|
||||
|
||||
-- ----- network_vlans -----
|
||||
DROP POLICY IF EXISTS "Network vlans: select" ON public.network_vlans;
|
||||
CREATE POLICY "Network vlans: select" ON public.network_vlans
|
||||
FOR SELECT USING (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid()
|
||||
AND p.role IN ('admin','it_staff','dispatcher','programmer'))
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "Network vlans: write" ON public.network_vlans;
|
||||
CREATE POLICY "Network vlans: write" ON public.network_vlans
|
||||
FOR ALL USING (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
) WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
);
|
||||
|
||||
-- ----- network_imports -----
|
||||
DROP POLICY IF EXISTS "Network imports: select" ON public.network_imports;
|
||||
CREATE POLICY "Network imports: select" ON public.network_imports
|
||||
FOR SELECT USING (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid()
|
||||
AND p.role IN ('admin','it_staff','dispatcher','programmer'))
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "Network imports: write" ON public.network_imports;
|
||||
CREATE POLICY "Network imports: write" ON public.network_imports
|
||||
FOR ALL USING (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
) WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- STORAGE BUCKET (private) for uploaded source documents
|
||||
-- =============================================================================
|
||||
-- The Edge Function `extract_topology` uses the service role to read from this
|
||||
-- bucket. Authenticated admin/it_staff clients upload via signed URLs or with
|
||||
-- their RLS-bound JWT.
|
||||
|
||||
INSERT INTO storage.buckets (id, name, public)
|
||||
VALUES ('network-imports', 'network-imports', false)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Storage policies: only admin/it_staff can upload; same group + dispatcher/programmer can read.
|
||||
DROP POLICY IF EXISTS "Network imports bucket: select" ON storage.objects;
|
||||
CREATE POLICY "Network imports bucket: select" ON storage.objects
|
||||
FOR SELECT USING (
|
||||
bucket_id = 'network-imports'
|
||||
AND EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid()
|
||||
AND p.role IN ('admin','it_staff','dispatcher','programmer'))
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "Network imports bucket: write" ON storage.objects;
|
||||
CREATE POLICY "Network imports bucket: write" ON storage.objects
|
||||
FOR INSERT WITH CHECK (
|
||||
bucket_id = 'network-imports'
|
||||
AND EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "Network imports bucket: update" ON storage.objects;
|
||||
CREATE POLICY "Network imports bucket: update" ON storage.objects
|
||||
FOR UPDATE USING (
|
||||
bucket_id = 'network-imports'
|
||||
AND EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "Network imports bucket: delete" ON storage.objects;
|
||||
CREATE POLICY "Network imports bucket: delete" ON storage.objects
|
||||
FOR DELETE USING (
|
||||
bucket_id = 'network-imports'
|
||||
AND EXISTS (SELECT 1 FROM public.profiles p
|
||||
WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff'))
|
||||
);
|
||||
Reference in New Issue
Block a user