Files
2026-07-12 10:17:17 -04:00

237 lines
9.3 KiB
Python

#!/usr/bin/env python3
"""
PocketBase v0.23+ Collection Schema Setup
Complete Firebase,PocketBase migration: define all fields for every collection,
then apply userId-based multi-tenancy rules.
The two-step process (schema first with empty rules, then rules) is required
because PocketBase validates rule expressions against existing fields.
IMPORTANT: This list MUST include EVERY field the web app writes.
PocketBase silently drops fields not in the schema (HTTP 200, no error).
Compare against: grep -rn '\.write\|formData\.\|newRO\.\|updateData\.\|financial\b' *.js
to find all written fields before deployment.
"""
import urllib.request, json, ssl, sys
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
BASE = "https://grajmedia.duckdns.org:3447" # change to your domain
ADMIN_EMAIL = "admin@example.com"
ADMIN_PASS = "your-password"
def api(method, path, data=None, token=None):
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = token
req = urllib.request.Request(
f"{BASE}{path}",
data=json.dumps(data).encode() if data else None,
headers=headers,
method=method
)
try:
resp = urllib.request.urlopen(req, context=ctx)
if resp.status == 204:
return {}
return json.loads(resp.read())
except urllib.error.HTTPError as e:
body = e.read().decode()
print(f" ERROR {e.code}: {body[:200]}")
return None
# --- Auth ---
auth = api("POST", "/api/collections/_superusers/auth-with-password",
{"identity": ADMIN_EMAIL, "password": ADMIN_PASS})
token = auth["token"]
# --- Collection Schemas ---
# Note: v0.23+ uses 'fields' key (not 'schema' from v0.22)
# Include the system 'id' field when PATCHing -- PocketBase requires it
ID_FIELD = {
"autogeneratePattern": "[a-z0-9]{15}",
"name": "id", "type": "text", "system": True, "required": True,
"max": 15, "min": 15, "pattern": "^[a-z0-9]+$",
"presentable": False, "primaryKey": True
}
# IMPORTANT: Every field the web app's JavaScript writes MUST be in this list.
# Missing fields are silently dropped by PocketBase on write.
NEW_FIELDS = {
"repairOrders": [
{"name": "userId", "type": "text", "required": True},
{"name": "customerName", "type": "text"},
{"name": "customerEmail", "type": "text"},
{"name": "customerPhone", "type": "text"},
{"name": "deviceModel", "type": "text"},
{"name": "deviceType", "type": "text"},
{"name": "issue", "type": "text"},
{"name": "status", "type": "text"}, # waiter / drop-off
{"name": "workStatus", "type": "text"}, # in-progress / waiting-for-parts / completed / waiting-for-pickup
{"name": "roNumber", "type": "text"}, # RO-XXXXXXX
{"name": "vehicleInfo", "type": "text"}, # "2020 Honda Accord"
{"name": "vin", "type": "text"}, # 17-char VIN
{"name": "mileage", "type": "text"},
{"name": "services", "type": "text"}, # comma-separated service names
{"name": "estimatedTime", "type": "text"}, # hours as string
{"name": "promisedTime", "type": "text"}, # ISO datetime string
{"name": "lastModified", "type": "text"}, # ISO datetime string
{"name": "completedTime", "type": "text"}, # ISO datetime string
{"name": "financial", "type": "json"}, # {cpTotal,cpCost,whTotal,whCost,grossProfit,...}
{"name": "priority", "type": "text"},
{"name": "createdAt", "type": "text"},
{"name": "updatedAt", "type": "text"},
{"name": "lastActivity", "type": "text"},
{"name": "technician", "type": "text"},
{"name": "notes", "type": "text"},
{"name": "price", "type": "number"},
{"name": "customerId", "type": "text"},
{"name": "quoteId", "type": "text"},
{"name": "invoiceId", "type": "text"},
{"name": "writeupTime", "type": "text"},
],
"appointments": [
{"name": "userId", "type": "text", "required": True},
{"name": "customerName", "type": "text"},
{"name": "customerEmail", "type": "text"},
{"name": "customerPhone", "type": "text"},
{"name": "appointmentDateTime", "type": "text"},
{"name": "vehicleInfo", "type": "text"},
{"name": "serviceType", "type": "text"},
{"name": "lastModified", "type": "text"},
{"name": "status", "type": "text"},
{"name": "type", "type": "text"},
{"name": "notes", "type": "text"},
{"name": "createdAt", "type": "text"},
{"name": "updatedAt", "type": "text"},
{"name": "repairOrderId", "type": "text"},
{"name": "duration", "type": "number"},
],
"quotes": [
{"name": "userId", "type": "text", "required": True},
{"name": "customerName", "type": "text"},
{"name": "customerEmail", "type": "text"},
{"name": "customerPhone", "type": "text"},
{"name": "deviceModel", "type": "text"},
{"name": "deviceType", "type": "text"},
{"name": "issue", "type": "text"},
{"name": "status", "type": "text"},
{"name": "createdAt", "type": "text"},
{"name": "updatedAt", "type": "text"},
{"name": "subtotal", "type": "number"},
{"name": "tax", "type": "number"},
{"name": "total", "type": "number"},
{"name": "notes", "type": "text"},
{"name": "customerId", "type": "text"},
{"name": "repairOrderId", "type": "text"},
],
"customers": [
{"name": "userId", "type": "text", "required": True},
{"name": "name", "type": "text"},
{"name": "email", "type": "text"},
{"name": "phone", "type": "text"},
{"name": "address", "type": "text"},
{"name": "vehicleInfo", "type": "text"},
{"name": "vin", "type": "text"},
{"name": "lastModified", "type": "text"},
{"name": "lastService", "type": "text"},
{"name": "preferredContact", "type": "text"}, # phone / email / text
{"name": "notes", "type": "text"},
{"name": "createdAt", "type": "text"},
{"name": "updatedAt", "type": "text"},
{"name": "lastVisit", "type": "text"},
{"name": "totalVisits", "type": "number"},
],
"services": [
{"name": "userId", "type": "text", "required": True},
{"name": "name", "type": "text", "required": True},
{"name": "description", "type": "text"},
{"name": "price", "type": "number"},
{"name": "category", "type": "text"},
{"name": "duration", "type": "number"},
{"name": "createdAt", "type": "text"},
{"name": "updatedAt", "type": "text"},
],
"tasks": [
{"name": "userId", "type": "text", "required": True},
{"name": "title", "type": "text", "required": True},
{"name": "description", "type": "text"},
{"name": "status", "type": "text"},
{"name": "priority", "type": "text"},
{"name": "dueDate", "type": "text"},
{"name": "createdAt", "type": "text"},
{"name": "updatedAt", "type": "text"},
{"name": "assignedTo", "type": "text"},
{"name": "repairOrderId", "type": "text"},
],
"settings": [
{"name": "userId", "type": "text", "required": True},
{"name": "name", "type": "text", "required": True},
],
}
RULES = {
"listRule": "userId = @request.auth.id",
"viewRule": "userId = @request.auth.id",
"createRule": "@request.auth.id != ''",
"updateRule": "userId = @request.auth.id",
"deleteRule": "userId = @request.auth.id",
}
# --- Apply schemas ---
cols = api("GET", "/api/collections", token=token)
col_map = {c["name"]: c["id"] for c in cols.get("items", []) if c["type"] == "base"}
for name, new_fields in NEW_FIELDS.items():
print(f"\n=== {name} ===")
col_id = col_map.get(name)
if not col_id:
print(f" Collection not found -- create it first")
continue
# Get existing fields (especially the system 'id' field)
full = api("GET", f"/api/collections/{col_id}", token=token)
if not full:
continue
existing = full.get("fields", [])
existing_names = {f["name"] for f in existing}
# Merge: keep all existing fields, add new ones
merged = existing.copy()
added = 0
for nf in new_fields:
if nf["name"] not in existing_names:
if "required" not in nf:
nf["required"] = False
merged.append(nf)
added += 1
if added == 0:
print(f" All fields already exist -- skipping")
continue
# Step 1: Set fields with empty rules
result = api("PATCH", f"/api/collections/{col_id}", {
"fields": merged,
"listRule": "", "viewRule": "", "createRule": "",
"updateRule": "", "deleteRule": ""
}, token=token)
if result:
fields_out = [f["name"] for f in result.get("fields", [])]
print(f" ✓ Added {added} fields , {fields_out}")
# Step 2: Apply rules now that fields exist
result2 = api("PATCH", f"/api/collections/{col_id}", RULES, token=token)
if result2:
print(f" ✓ Rules applied")
else:
print(f" ✗ Rules failed")
else:
print(f" ✗ Schema update failed")
print("\nDone!")