From cac759a64db4b5d4fc4d4ef16572b7661dddade0 Mon Sep 17 00:00:00 2001 From: Sonny May <102835434+sonnymay@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:43:15 -0500 Subject: [PATCH] fix supportops supabase recovery --- README.md | 2 + backend/database.py | 65 ++++++++-- backend/main.py | 75 +++++++++-- backend/supabase/schema.sql | 249 ++++++++++++++++++++++++++++++++++++ backend/test_database.py | 50 ++++++-- backend/test_main.py | 86 +++++++++++++ 6 files changed, 494 insertions(+), 33 deletions(-) create mode 100644 backend/supabase/schema.sql diff --git a/README.md b/README.md index 0386ea6..1186e58 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,8 @@ uvicorn main:app --reload ``` API runs at `http://localhost:8000`. Health check: `GET /health`. Interactive docs: `/docs`. +Dependency check: `GET /health/dependencies`. +The Supabase table setup lives in `backend/supabase/schema.sql`. ### Frontend diff --git a/backend/database.py b/backend/database.py index 09919d3..3d24182 100644 --- a/backend/database.py +++ b/backend/database.py @@ -7,9 +7,31 @@ SUPABASE_URL = os.getenv("SUPABASE_URL") SUPABASE_KEY = os.getenv("SUPABASE_KEY") +DATABASE_UNAVAILABLE_DETAIL = ( + "SupportOps database is unavailable. Check SUPABASE_URL, SUPABASE_KEY, " + "and Supabase table setup in Render." +) + + +class DatabaseConfigError(RuntimeError): + """Raised when required Supabase configuration is missing.""" + + +class DatabaseRequestError(RuntimeError): + """Raised when Supabase rejects or cannot complete a request.""" + + +def is_configured(): + return bool(SUPABASE_URL and SUPABASE_KEY) + + +def require_config(): + if not is_configured(): + raise DatabaseConfigError("SUPABASE_URL and SUPABASE_KEY must be configured in Render.") def get_headers(): + require_config() return { "apikey": SUPABASE_KEY, "Authorization": f"Bearer {SUPABASE_KEY}", @@ -18,25 +40,44 @@ def get_headers(): } +def handle_response(response): + try: + response.raise_for_status() + except requests.RequestException as error: + raise DatabaseRequestError(DATABASE_UNAVAILABLE_DETAIL) from error + + if response.status_code == 204 or not response.text: + return None + + try: + return response.json() + except ValueError as error: + raise DatabaseRequestError(DATABASE_UNAVAILABLE_DETAIL) from error + + +def request_supabase(method, table, params="", data=None): + query = f"?{params}" if params else "" + response = requests.request( + method, + f"{SUPABASE_URL}/rest/v1/{table}{query}", + headers=get_headers(), + json=data, + timeout=20, + ) + return handle_response(response) + + def db_get(table, params=""): - url = f"{SUPABASE_URL}/rest/v1/{table}?{params}" - r = requests.get(url, headers=get_headers()) - return r.json() + return request_supabase("GET", table, params=params) def db_post(table, data): - url = f"{SUPABASE_URL}/rest/v1/{table}" - r = requests.post(url, headers=get_headers(), json=data) - return r.json() + return request_supabase("POST", table, data=data) def db_patch(table, id, data): - url = f"{SUPABASE_URL}/rest/v1/{table}?id=eq.{id}" - r = requests.patch(url, headers=get_headers(), json=data) - return r.json() + return request_supabase("PATCH", table, params=f"id=eq.{id}", data=data) def db_delete(table, id): - url = f"{SUPABASE_URL}/rest/v1/{table}?id=eq.{id}" - r = requests.delete(url, headers=get_headers()) - return r.json() + return request_supabase("DELETE", table, params=f"id=eq.{id}") diff --git a/backend/main.py b/backend/main.py index 81a233a..ccfe799 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,9 +1,11 @@ -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Request, Response from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse from pydantic import BaseModel import ai -from database import db_delete, db_get, db_patch, db_post +import database +from database import DatabaseConfigError, DatabaseRequestError, db_delete, db_get, db_patch, db_post app = FastAPI(title="SupportOps API") @@ -21,6 +23,16 @@ ) +@app.exception_handler(DatabaseConfigError) +def database_config_error(_request: Request, exc: DatabaseConfigError): + return JSONResponse(status_code=503, content={"detail": str(exc)}) + + +@app.exception_handler(DatabaseRequestError) +def database_request_error(_request: Request, exc: DatabaseRequestError): + return JSONResponse(status_code=503, content={"detail": str(exc)}) + + # --- Models --- class Customer(BaseModel): name: str @@ -60,12 +72,50 @@ class RMA(BaseModel): resolution_status: str | None = "Pending" +def clean_empty_strings(data: dict, *keys: str) -> dict: + for key in keys: + if data.get(key) == "": + data[key] = None + return data + + +def model_data(model: BaseModel) -> dict: + return model.model_dump() + + # --- Health --- @app.get("/health") def health(): return {"status": "ok"} +@app.get("/health/dependencies") +def dependency_health(response: Response): + configured = database.is_configured() + reachable = False + detail = None + + if not configured: + response.status_code = 503 + detail = "SUPABASE_URL and SUPABASE_KEY must be configured in Render." + else: + try: + db_get("customers", "select=id&limit=1") + reachable = True + except (DatabaseConfigError, DatabaseRequestError) as error: + response.status_code = 503 + detail = str(error) + + return { + "status": "ok" if reachable else "error", + "supabase": { + "configured": configured, + "reachable": reachable, + "detail": detail, + }, + } + + # --- Customers --- @app.get("/customers") def get_customers(): @@ -74,12 +124,12 @@ def get_customers(): @app.post("/customers") def create_customer(c: Customer): - return db_post("customers", c.dict()) + return db_post("customers", model_data(c)) @app.put("/customers/{id}") def update_customer(id: str, c: Customer): - return db_patch("customers", id, c.dict()) + return db_patch("customers", id, model_data(c)) @app.delete("/customers/{id}") @@ -95,12 +145,12 @@ def get_devices(): @app.post("/devices") def create_device(d: Device): - return db_post("devices", d.dict()) + return db_post("devices", clean_empty_strings(model_data(d), "customer_id")) @app.put("/devices/{id}") def update_device(id: str, d: Device): - return db_patch("devices", id, d.dict()) + return db_patch("devices", id, clean_empty_strings(model_data(d), "customer_id")) # --- Tickets --- @@ -119,12 +169,15 @@ def get_ticket(id: str): @app.post("/tickets") def create_ticket(t: Ticket): - return db_post("tickets", t.dict()) + return db_post( + "tickets", + clean_empty_strings(model_data(t), "customer_id", "device_id", "assigned_user_id"), + ) @app.put("/tickets/{id}") def update_ticket(id: str, t: Ticket): - data = t.dict() + data = clean_empty_strings(model_data(t), "customer_id", "device_id", "assigned_user_id") old = db_get("tickets", f"id=eq.{id}&select=status") if old and old[0]["status"] != data["status"]: db_post( @@ -147,7 +200,7 @@ def get_notes(id: str): @app.post("/notes") def create_note(n: TicketNote): - return db_post("ticket_notes", n.dict()) + return db_post("ticket_notes", model_data(n)) # --- History --- @@ -164,12 +217,12 @@ def get_rmas(): @app.post("/rmas") def create_rma(r: RMA): - return db_post("rmas", r.dict()) + return db_post("rmas", model_data(r)) @app.put("/rmas/{id}") def update_rma(id: str, r: RMA): - return db_patch("rmas", id, r.dict()) + return db_patch("rmas", id, model_data(r)) # --- Dashboard --- diff --git a/backend/supabase/schema.sql b/backend/supabase/schema.sql new file mode 100644 index 0000000..0a3dd2a --- /dev/null +++ b/backend/supabase/schema.sql @@ -0,0 +1,249 @@ +create table if not exists customers ( + id uuid primary key default gen_random_uuid(), + name text not null, + email text, + phone text, + company text, + created_at timestamptz default now() +); + +create table if not exists devices ( + id uuid primary key default gen_random_uuid(), + serial_number text not null unique, + model text, + product_type text, + customer_id uuid references customers(id) on delete set null, + created_at timestamptz default now() +); + +create table if not exists tickets ( + id uuid primary key default gen_random_uuid(), + title text not null, + description text, + status text default 'Open' check ( + status in ('Open', 'In Progress', 'Waiting on Customer', 'Resolved', 'Closed') + ), + priority text default 'Medium' check (priority in ('Low', 'Medium', 'High', 'Critical')), + customer_id uuid references customers(id) on delete set null, + device_id uuid references devices(id) on delete set null, + assigned_user_id uuid, + created_at timestamptz default now(), + updated_at timestamptz default now() +); + +create table if not exists ticket_notes ( + id uuid primary key default gen_random_uuid(), + ticket_id uuid references tickets(id) on delete cascade, + note_text text not null, + created_by text, + created_at timestamptz default now() +); + +create table if not exists ticket_history ( + id uuid primary key default gen_random_uuid(), + ticket_id uuid references tickets(id) on delete cascade, + old_status text, + new_status text, + changed_by text, + changed_at timestamptz default now() +); + +create table if not exists rmas ( + id uuid primary key default gen_random_uuid(), + ticket_id uuid references tickets(id) on delete cascade, + rma_number text not null unique, + serial_number text, + shipping_status text default 'Pending', + resolution_status text default 'Pending', + created_at timestamptz default now(), + closed_at timestamptz +); + +create index if not exists devices_customer_id_idx on devices(customer_id); +create index if not exists tickets_customer_id_idx on tickets(customer_id); +create index if not exists tickets_device_id_idx on tickets(device_id); +create index if not exists ticket_notes_ticket_id_idx on ticket_notes(ticket_id); +create index if not exists ticket_history_ticket_id_idx on ticket_history(ticket_id); +create index if not exists rmas_ticket_id_idx on rmas(ticket_id); + +alter table customers enable row level security; +alter table devices enable row level security; +alter table tickets enable row level security; +alter table ticket_notes enable row level security; +alter table ticket_history enable row level security; +alter table rmas enable row level security; + +insert into customers (id, name, email, phone, company, created_at) +values + ( + '00000000-0000-4000-8000-000000000001', + 'Acme Operations', + 'ops@acme.example', + '555-0101', + 'Acme Inc', + timezone('utc', now()) - interval '6 days' + ), + ( + '00000000-0000-4000-8000-000000000002', + 'Northwind Support', + 'support@northwind.example', + '555-0130', + 'Northwind Traders', + timezone('utc', now()) - interval '4 days' + ), + ( + '00000000-0000-4000-8000-000000000003', + 'Contoso Field Team', + 'field@contoso.example', + '555-0177', + 'Contoso', + timezone('utc', now()) - interval '2 days' + ) +on conflict (id) do nothing; + +insert into devices (id, serial_number, model, product_type, customer_id, created_at) +values + ( + '00000000-0000-4000-8000-000000000101', + 'RTR-ACME-1001', + 'EdgeRouter X', + 'Router', + '00000000-0000-4000-8000-000000000001', + timezone('utc', now()) - interval '5 days' + ), + ( + '00000000-0000-4000-8000-000000000102', + 'LAP-NW-2044', + 'Latitude 7440', + 'Laptop', + '00000000-0000-4000-8000-000000000002', + timezone('utc', now()) - interval '3 days' + ), + ( + '00000000-0000-4000-8000-000000000103', + 'AP-CON-3319', + 'UniFi U6 Pro', + 'Access Point', + '00000000-0000-4000-8000-000000000003', + timezone('utc', now()) - interval '1 day' + ) +on conflict (id) do nothing; + +insert into tickets ( + id, + title, + description, + status, + priority, + customer_id, + device_id, + assigned_user_id, + created_at +) +values + ( + '00000000-0000-4000-8000-000000000201', + 'Router drops WAN during backups', + 'WAN interface flaps when nightly backup traffic spikes.', + 'Open', + 'Critical', + '00000000-0000-4000-8000-000000000001', + '00000000-0000-4000-8000-000000000101', + null, + timezone('utc', now()) - interval '2 days' + ), + ( + '00000000-0000-4000-8000-000000000202', + 'Laptop battery swelling', + 'User reports trackpad lifting and chassis gap near battery bay.', + 'In Progress', + 'High', + '00000000-0000-4000-8000-000000000002', + '00000000-0000-4000-8000-000000000102', + null, + timezone('utc', now()) - interval '1 day' + ), + ( + '00000000-0000-4000-8000-000000000203', + 'Conference room AP replaced', + 'Intermittent Wi-Fi resolved after AP swap and channel plan update.', + 'Closed', + 'Medium', + '00000000-0000-4000-8000-000000000003', + '00000000-0000-4000-8000-000000000103', + null, + timezone('utc', now()) - interval '12 hours' + ) +on conflict (id) do nothing; + +insert into ticket_notes (ticket_id, note_text, created_by, created_at) +select + '00000000-0000-4000-8000-000000000201', + 'Observed WAN drops line up with backup window. Asked customer for modem logs.', + 'Agent', + timezone('utc', now()) - interval '36 hours' +where not exists ( + select 1 from ticket_notes where ticket_id = '00000000-0000-4000-8000-000000000201' +) +union all +select + '00000000-0000-4000-8000-000000000202', + 'Advised customer to stop using laptop and start RMA path for battery safety.', + 'Agent', + timezone('utc', now()) - interval '18 hours' +where not exists ( + select 1 from ticket_notes where ticket_id = '00000000-0000-4000-8000-000000000202' +) +union all +select + '00000000-0000-4000-8000-000000000203', + 'Replacement AP installed. RSSI and roaming tests passed.', + 'Agent', + timezone('utc', now()) - interval '8 hours' +where not exists ( + select 1 from ticket_notes where ticket_id = '00000000-0000-4000-8000-000000000203' +); + +insert into ticket_history (ticket_id, old_status, new_status, changed_by, changed_at) +select + '00000000-0000-4000-8000-000000000201', + null, + 'Open', + 'Agent', + timezone('utc', now()) - interval '2 days' +where not exists ( + select 1 from ticket_history where ticket_id = '00000000-0000-4000-8000-000000000201' +) +union all +select + '00000000-0000-4000-8000-000000000202', + 'Open', + 'In Progress', + 'Agent', + timezone('utc', now()) - interval '16 hours' +where not exists ( + select 1 from ticket_history where ticket_id = '00000000-0000-4000-8000-000000000202' +) +union all +select + '00000000-0000-4000-8000-000000000203', + 'In Progress', + 'Closed', + 'Agent', + timezone('utc', now()) - interval '8 hours' +where not exists ( + select 1 from ticket_history where ticket_id = '00000000-0000-4000-8000-000000000203' +); + +insert into rmas (id, ticket_id, rma_number, serial_number, shipping_status, resolution_status, created_at) +values + ( + '00000000-0000-4000-8000-000000000301', + '00000000-0000-4000-8000-000000000202', + 'RMA-2026-1001', + 'LAP-NW-2044', + 'Label Sent', + 'Pending', + timezone('utc', now()) - interval '12 hours' + ) +on conflict (id) do nothing; diff --git a/backend/test_database.py b/backend/test_database.py index a8406a9..fbccf45 100644 --- a/backend/test_database.py +++ b/backend/test_database.py @@ -1,5 +1,8 @@ from unittest.mock import MagicMock +import pytest +import requests + import database @@ -17,34 +20,61 @@ def test_database_helpers_call_supabase_rest_api(monkeypatch): monkeypatch.setattr(database, "SUPABASE_URL", "https://example.supabase.co") monkeypatch.setattr(database, "SUPABASE_KEY", "secret") fake_response = MagicMock() + fake_response.status_code = 200 + fake_response.text = '[{"id":"1"}]' fake_response.json.return_value = [{"id": "1"}] - fake_requests = MagicMock() - fake_requests.get.return_value = fake_response - fake_requests.post.return_value = fake_response - fake_requests.patch.return_value = fake_response - fake_requests.delete.return_value = fake_response - monkeypatch.setattr(database, "requests", fake_requests) + fake_request = MagicMock(return_value=fake_response) + monkeypatch.setattr(database.requests, "request", fake_request) assert database.db_get("tickets", "id=eq.1") == [{"id": "1"}] assert database.db_post("tickets", {"title": "Issue"}) == [{"id": "1"}] assert database.db_patch("tickets", "1", {"status": "Closed"}) == [{"id": "1"}] assert database.db_delete("tickets", "1") == [{"id": "1"}] - fake_requests.get.assert_called_once_with( + fake_request.assert_any_call( + "GET", "https://example.supabase.co/rest/v1/tickets?id=eq.1", headers=database.get_headers(), + json=None, + timeout=20, ) - fake_requests.post.assert_called_once_with( + fake_request.assert_any_call( + "POST", "https://example.supabase.co/rest/v1/tickets", headers=database.get_headers(), json={"title": "Issue"}, + timeout=20, ) - fake_requests.patch.assert_called_once_with( + fake_request.assert_any_call( + "PATCH", "https://example.supabase.co/rest/v1/tickets?id=eq.1", headers=database.get_headers(), json={"status": "Closed"}, + timeout=20, ) - fake_requests.delete.assert_called_once_with( + fake_request.assert_any_call( + "DELETE", "https://example.supabase.co/rest/v1/tickets?id=eq.1", headers=database.get_headers(), + json=None, + timeout=20, ) + + +def test_missing_supabase_config_raises_clear_error(monkeypatch): + monkeypatch.setattr(database, "SUPABASE_URL", "") + monkeypatch.setattr(database, "SUPABASE_KEY", "") + + with pytest.raises(database.DatabaseConfigError, match="SUPABASE_URL"): + database.db_get("tickets") + + +def test_supabase_http_error_raises_database_error(monkeypatch): + monkeypatch.setattr(database, "SUPABASE_URL", "https://example.supabase.co") + monkeypatch.setattr(database, "SUPABASE_KEY", "secret") + fake_response = MagicMock() + fake_response.raise_for_status.side_effect = requests.HTTPError("503") + monkeypatch.setattr(database.requests, "request", MagicMock(return_value=fake_response)) + + with pytest.raises(database.DatabaseRequestError, match="SupportOps database is unavailable"): + database.db_get("tickets") diff --git a/backend/test_main.py b/backend/test_main.py index df8dea2..4893685 100644 --- a/backend/test_main.py +++ b/backend/test_main.py @@ -36,6 +36,49 @@ def test_health_returns_ok(client): assert res.json() == {"status": "ok"} +def test_dependency_health_returns_ok_when_supabase_reachable(client, monkeypatch): + test_client, main = client + monkeypatch.setattr(main.database, "is_configured", lambda: True) + monkeypatch.setattr(main, "db_get", lambda table, params="": []) + + res = test_client.get("/health/dependencies") + + assert res.status_code == 200 + assert res.json() == { + "status": "ok", + "supabase": {"configured": True, "reachable": True, "detail": None}, + } + + +def test_dependency_health_returns_503_when_supabase_missing_config(client, monkeypatch): + test_client, main = client + monkeypatch.setattr(main.database, "is_configured", lambda: False) + + res = test_client.get("/health/dependencies") + + assert res.status_code == 503 + assert res.json()["status"] == "error" + assert res.json()["supabase"]["configured"] is False + + +def test_dependency_health_returns_503_when_supabase_query_fails(client, monkeypatch): + test_client, main = client + monkeypatch.setattr(main.database, "is_configured", lambda: True) + monkeypatch.setattr( + main, + "db_get", + lambda table, params="": (_ for _ in ()).throw( + main.DatabaseRequestError("SupportOps database is unavailable.") + ), + ) + + res = test_client.get("/health/dependencies") + + assert res.status_code == 503 + assert res.json()["status"] == "error" + assert res.json()["supabase"]["reachable"] is False + + def test_get_ticket_not_found_returns_404(client, monkeypatch): test_client, main = client monkeypatch.setattr(main, "db_get", lambda table, params="": []) @@ -112,6 +155,27 @@ def test_device_routes_delegate_to_database(client, monkeypatch): assert calls[1][0:3] == ("patch", "devices", "d1") +def test_device_routes_convert_blank_customer_id_to_null(client, monkeypatch): + test_client, main = client + calls = [] + monkeypatch.setattr(main, "db_post", lambda table, data: calls.append((table, data)) or data) + + res = test_client.post( + "/devices", json={"serial_number": "SN-1", "model": "Router", "customer_id": ""} + ) + + assert res.status_code == 200 + assert calls[0] == ( + "devices", + { + "serial_number": "SN-1", + "model": "Router", + "product_type": None, + "customer_id": None, + }, + ) + + def test_ticket_routes_create_update_and_record_status_history(client, monkeypatch): test_client, main = client posts = [] @@ -142,6 +206,28 @@ def fake_get(table, params=""): ) in posts +def test_ticket_routes_convert_blank_relationship_ids_to_null(client, monkeypatch): + test_client, main = client + calls = [] + monkeypatch.setattr(main, "db_post", lambda table, data: calls.append((table, data)) or data) + + res = test_client.post( + "/tickets", + json={ + "title": "Printer down", + "customer_id": "", + "device_id": "", + "assigned_user_id": "", + }, + ) + + assert res.status_code == 200 + assert calls[0][0] == "tickets" + assert calls[0][1]["customer_id"] is None + assert calls[0][1]["device_id"] is None + assert calls[0][1]["assigned_user_id"] is None + + def test_notes_and_history_routes_delegate_to_database(client, monkeypatch): test_client, main = client monkeypatch.setattr(