Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
65 changes: 53 additions & 12 deletions backend/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand All @@ -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}")
75 changes: 64 additions & 11 deletions backend/main.py
Original file line number Diff line number Diff line change
@@ -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")

Expand All @@ -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
Expand Down Expand Up @@ -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():
Expand All @@ -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}")
Expand All @@ -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 ---
Expand All @@ -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(
Expand All @@ -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 ---
Expand All @@ -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 ---
Expand Down
Loading