#!/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!")