initial commit
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
---
|
||||
name: firebase-to-pocketbase-migration
|
||||
description: Migrate a Firebase web app to self-hosted PocketBase — build a Firebase-compatible adapter, swap imports, and deploy with same-origin nginx proxy.
|
||||
triggers:
|
||||
- "migrate from Firebase"
|
||||
- "replace Firebase with PocketBase"
|
||||
- "Firebase to PocketBase"
|
||||
- "remove Firebase dependency"
|
||||
- "self-host instead of Firebase"
|
||||
---
|
||||
|
||||
# Firebase → PocketBase Migration
|
||||
|
||||
Replace Firebase (Auth + Firestore) with PocketBase without rewriting the app. Build a thin compatibility adapter, swap one import per file, and deploy behind same-origin nginx so the PocketBase JS SDK works without CORS.
|
||||
|
||||
## Strategy: Adapter, Don't Rewrite
|
||||
|
||||
Firebase apps import from `firebase.js` (or CDN) and call `signInWithEmailAndPassword(auth, ...)`, `collection(db, 'name')`, `addDoc(ref, data)`, `getDocs(query(...))`, etc. PocketBase has a different API surface.
|
||||
|
||||
**The adapter** (`pocketbase.js`) exports `auth` and `db` objects that mirror the Firebase surface, backed by PocketBase under the hood. All 13+ app files keep their existing `collection()`, `query()`, `where()`, `getDocs()`, `addDoc()`, etc. calls unchanged.
|
||||
|
||||
## Step 1: Map the Firestore Data Model
|
||||
|
||||
Search for all Firestore references:
|
||||
|
||||
```bash
|
||||
grep -rn "collection(db," *.js shared/*.js
|
||||
grep -rn "gstatic.com/firebasejs" *.js shared/*.js
|
||||
```
|
||||
|
||||
Identify:
|
||||
- **Top-level collections** (e.g., `repairOrders`, `appointments`)
|
||||
- **Sub-collections** (e.g., `users/{uid}/services`, `users/{uid}/customers`)
|
||||
- **Dynamic imports** (`await import("...firebase-firestore.js")`)
|
||||
- **Auth functions used** (`signOut`, `onAuthStateChanged`, `updateProfile`, `sendPasswordResetEmail`)
|
||||
|
||||
## Step 2: Flatten Sub-Collections
|
||||
|
||||
PocketBase has no native sub-collections. Flatten them into top-level collections with a `userId` field:
|
||||
|
||||
| Firestore | PocketBase Collection | Key Field |
|
||||
|---|---|---|
|
||||
| `repairOrders` | `repairOrders` | `userId` |
|
||||
| `users/{uid}/services` | `services` | `userId` |
|
||||
| `users/{uid}/customers` | `customers` | `userId` |
|
||||
| `users/{uid}/settings/{docName}` | `settings` | `userId` + `name` |
|
||||
|
||||
The adapter handles the path translation transparently — app code still writes `collection(db, 'users', uid, 'services')`.
|
||||
|
||||
## Step 3: Create PocketBase Collections
|
||||
|
||||
Use the Admin API to create collections before writing any data. **Create with empty rules first, then PATCH rules after the schema exists** — rules that reference fields fail validation if the field doesn't exist yet.
|
||||
|
||||
### v0.22 and earlier (uses `schema` key)
|
||||
|
||||
```python
|
||||
api("POST", "/api/collections", {
|
||||
"name": "repairOrders", "type": "base",
|
||||
"schema": [{"name": "userId", "type": "text", "required": True}],
|
||||
"listRule": "", "viewRule": "", "createRule": "",
|
||||
"updateRule": "", "deleteRule": ""
|
||||
}, token)
|
||||
```
|
||||
|
||||
### v0.23+ (uses `fields` key)
|
||||
|
||||
```python
|
||||
api("POST", "/api/collections", {
|
||||
"name": "repairOrders", "type": "base",
|
||||
"fields": [{"name": "userId", "type": "text", "required": True}],
|
||||
"listRule": "", "viewRule": "", "createRule": "",
|
||||
"updateRule": "", "deleteRule": ""
|
||||
}, token)
|
||||
```
|
||||
|
||||
To check your PocketBase version: `docker exec pocketbase /usr/local/bin/pocketbase --version`
|
||||
|
||||
### Two-step: schema first, rules second
|
||||
|
||||
```python
|
||||
# Step 1: Set schema with empty rules
|
||||
api("PATCH", f"/api/collections/{col_id}", {
|
||||
"fields": [...all fields including system id field...],
|
||||
"listRule": "", "viewRule": "", "createRule": "",
|
||||
"updateRule": "", "deleteRule": ""
|
||||
}, token)
|
||||
|
||||
# Step 2: Apply rules now that fields exist
|
||||
api("PATCH", f"/api/collections/{col_id}", {
|
||||
"listRule": "userId = @request.auth.id",
|
||||
"viewRule": "userId = @request.auth.id",
|
||||
"createRule": "@request.auth.id != ''",
|
||||
"updateRule": "userId = @request.auth.id",
|
||||
"deleteRule": "userId = @request.auth.id",
|
||||
}, token)
|
||||
```
|
||||
|
||||
**⚠️ You MUST include the system `id` field** (with its existing properties) when PATCHing fields in v0.23+, or the update may be rejected. Fetch the collection first to get the existing field definitions, then merge new fields into them.
|
||||
|
||||
### Define ALL fields, not just userId
|
||||
|
||||
Firebase/Firestore is schemaless — you can add any field to a document dynamically. PocketBase is **strictly typed** — any field not defined in the collection schema is **silently dropped on write** (HTTP 200 is returned but the data is not stored). You MUST define every field the app writes before any data operations.
|
||||
|
||||
Use `references/collection-schema-setup.py` as a template — it defines complete schemas for all common Firebase collection types and handles the two-step schema+rules process.
|
||||
|
||||
Create one collection per top-level name: `repairOrders`, `appointments`, `quotes`, `tasks`, `services`, `customers`, `settings`.
|
||||
|
||||
## Step 4: Build the Adapter
|
||||
|
||||
Use `templates/pocketbase-adapter.js` as a starting point. The adapter handles Auth + Firestore CRUD with PocketBase underneath. Also see `templates/same-origin-nginx.conf` for the nginx config, `references/create-collections.py` for the original collection creation script (v0.22), and **`references/collection-schema-setup.py`** for the complete field schema definitions with v0.23+ two-step process.
|
||||
|
||||
**⚠️ Critical compatibility requirements** for `getDoc`, `getDocs`, `addDoc`, and `onSnapshot` are documented in **`references/adapter-firestore-compat-patterns.md`**. Read this before writing or debugging the adapter — the return shapes must match the Firestore SDK exactly or every app file silently breaks.
|
||||
|
||||
### Auth surface (Firebase-compatible)
|
||||
- `auth.currentUser` → `{ uid, email, displayName }`
|
||||
- `auth.onAuthStateChanged(callback)` → fires on change + returns unsubscribe
|
||||
- `signInWithEmailAndPassword(auth, email, password)` → PocketBase `authWithPassword()`
|
||||
- `createUserWithEmailAndPassword(auth, email, password)` → PocketBase `create()` + `authWithPassword()`
|
||||
- `signOut(auth)` → `pb.authStore.clear()`
|
||||
- `sendPasswordResetEmail(auth, email)` → PocketBase `requestPasswordReset()`
|
||||
- `updateProfile(user, {displayName})` → PocketBase `update()`
|
||||
|
||||
### Firestore surface
|
||||
- `collection(db, name)` / `collection(db, 'users', uid, 'sub')` — top-level and sub-collection
|
||||
- `doc(db, 'col', id)` / `doc(db, 'users', uid, 'settings', name)` — top-level and nested
|
||||
- `query(ref, where(field, op, val), orderBy(field, dir))` — compiled to PocketBase filter strings
|
||||
- `getDocs(query)` — must return Firestore-compatible **snapshot shape**: `{docs: [...], size: N, empty: bool, forEach(cb)}`. Not a raw array — every consumer calls `.docs.map()`, `.empty`, `.size`, or `.forEach()`.
|
||||
- `getDoc(docRef)` → `pb.collection(name).getOne(id)`. **⚠️ Must return `exists` as a function** (`exists: () => true`/`exists: () => false`), not a boolean. Firestore API calls `.exists()` everywhere.
|
||||
- `addDoc(ref, data)` → `pb.collection(name).create(data)`. **⚠️ Must inject `userId` from sub-collection ref.** When app code writes `collection(db, 'users', uid, 'services')`, the adapter tracks `_userId` on the ref but `create()` discards it. Always: `if (ref._userId && !data.userId) { data.userId = ref._userId; }` before calling `create()`.
|
||||
- `updateDoc(docRef, data)` → `pb.collection(name).update(id, data)`
|
||||
- `deleteDoc(docRef)` → `pb.collection(name).delete(id)`
|
||||
- `onSnapshot(queryOrDoc, callback)` — **⚠️ Must pass snapshot shape to callback.** A bare `getDocs(query).then(docs => callback({docs}))` is wrong. Wrap in `createQuerySnapshot(docs)` that includes `size`, `empty`, `forEach`. Reference the same helper used by `getDocs`.
|
||||
- `serverTimestamp()` → `new Date().toISOString()`
|
||||
- `Timestamp` class — wraps Date for `.toDate()` calls on existing timestamp fields
|
||||
|
||||
### Query operator mapping
|
||||
| Firebase | PocketBase Filter |
|
||||
|---|---|
|
||||
| `where('field', '==', val)` | `field = "val"` |
|
||||
| `where('field', 'in', [a,b])` | `(field ?= "a" \|\| field ?= "b")` |
|
||||
| `orderBy('field', 'desc')` | `-field` sort parameter |
|
||||
|
||||
### Data conversion
|
||||
- **To PB**: Nested objects get `JSON.stringify()`'d. `undefined` values dropped. Dates → ISO strings.
|
||||
- **From PB**: ISO strings on known timestamp fields → `Timestamp` objects. JSON strings in object-like fields → parsed back.
|
||||
|
||||
## Step 5: Swap All Imports
|
||||
|
||||
Replace Firebase CDN imports with the adapter in one sweep:
|
||||
|
||||
```bash
|
||||
# Static imports
|
||||
find . -name "*.js" -exec sed -i 's|from "./firebase.js"|from "./pocketbase.js"|g' {} +
|
||||
find . -name "*.js" -exec sed -i 's|from "https://www\.gstatic\.com/firebasejs/[^"]*firebase-auth\.js"|from "./pocketbase.js"|g' {} +
|
||||
find . -name "*.js" -exec sed -i 's|from "https://www\.gstatic\.com/firebasejs/[^"]*firebase-firestore\.js"|from "./pocketbase.js"|g' {} +
|
||||
|
||||
# Dynamic imports
|
||||
find . -name "*.js" -exec sed -i 's|await import("https://www\.gstatic\.com/firebasejs/[^"]*firebase-auth\.js")|await import("./pocketbase.js")|g' {} +
|
||||
find . -name "*.js" -exec sed -i 's|await import("https://www\.gstatic\.com/firebasejs/[^"]*firebase-firestore\.js")|await import("./pocketbase.js")|g' {} +
|
||||
|
||||
# Also check login.html for inline module scripts
|
||||
# Also check any single-quoted import() calls
|
||||
```
|
||||
|
||||
Verify zero Firebase references remain:
|
||||
```bash
|
||||
grep -rn 'gstatic.com/firebasejs' *.js shared/*.js *.html
|
||||
```
|
||||
|
||||
## Step 6: Same-Origin nginx (No CORS)
|
||||
|
||||
PocketBase SDK uses `window.location.origin`. Serve the static app AND proxy `/api/` to PocketBase from the same nginx server block:
|
||||
|
||||
Use `templates/same-origin-nginx.conf` as the template. Key points:
|
||||
- `root /path/to/app;` serves static files
|
||||
- `location /api/ { proxy_pass http://127.0.0.1:8091; }` proxies PocketBase
|
||||
- `location /_/ { proxy_pass ... }` for admin UI access
|
||||
- SPA fallback: `try_files $uri $uri/ /index.html;`
|
||||
- WebSocket headers for realtime: `Upgrade`, `Connection`, `proxy_read_timeout 86400`
|
||||
|
||||
This avoids CORS entirely — PocketBase calls go to `/api/collections/users/auth-with-password` on the same origin.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **`onAuthStateChanged` must be exported as a standalone function.** Many files import `import { onAuthStateChanged } from "./pocketbase.js"` and call `onAuthStateChanged(auth, callback)`. If you only export it as `auth.onAuthStateChanged` (a method on the auth object), those imports resolve to `undefined` and the auth check silently does nothing — user sees the app without any auth redirect. Always add a standalone wrapper: `function onAuthStateChanged(authRef, callback) { return auth.onAuthStateChanged(callback); }` and export it alongside the other functions.
|
||||
- **Settings pages need `updatePassword`, `reauthenticateWithCredential`, `EmailAuthProvider`.** If the app has a profile/settings page, these Firebase Auth functions are imported. PocketBase equivalents: `updatePassword` calls `pb.collection('users').update(uid, {password, passwordConfirm})`. `reauthenticateWithCredential` re-runs `authWithPassword`. `EmailAuthProvider.credential(email, pass)` returns a credential object for re-auth.
|
||||
- **`setDoc`, `limit`, `onSnapshot` are commonly imported but easy to miss.** `setDoc` = upsert (try `update`, fallback to `create` with explicit ID). `limit(n)` returns a constraint object used in query building. `onSnapshot` can be a simple polling one-shot that calls `getDocs`/`getDoc` then fires the callback — most apps only need the initial snapshot, not real-time.
|
||||
- **`limit` must be handled in `getDocs`.** When a query has a limit constraint, use `pb.collection(name).getList(1, limitVal, options)` instead of `getFullList()`. `getFullList` fetches all pages; `getList` respects the pagination cap.
|
||||
|
||||
- **PocketBase SDK auto-cancellation (`requestKey: null`).** The PocketBase JS SDK auto-cancels in-flight requests that share the same `requestKey` (derived from method + URL by default). All `create` calls to the same collection share a key, so a double-submit cancels the first request with "auto-cancelled" error. Fix: pass `{ requestKey: null }` to ALL mutating calls — `pb.collection(name).create(data, { requestKey: null })`, `pb.collection(name).update(id, data, { requestKey: null })`, and the same inside `setDoc`'s upsert paths. Also add a client-side guard: disable the submit button on first click to prevent double submissions.
|
||||
- **`index.html` may load `firebase.js` via `<script>` tag.** `<script type="module" src="firebase.js"></script>` needs to become `src="pocketbase.js"`. HTML files are easy to miss when swapping imports in `*.js` files only.
|
||||
- **Mount paths must match entrypoint flags** (see `pocketbase-setup` skill). The image uses `--dir=/pb_data --publicDir=/pb_public`.
|
||||
- **Docker restart ignores compose changes.** After editing volumes/ports, use `down && up -d`.
|
||||
- **Admin `upsert` needs `--dir=/pb_data`** flag when running inside container.
|
||||
- **Auth API uses `_superusers`, not `admins`** in PocketBase v0.22+.
|
||||
- **Users collection system ID is `_pb_users_auth_`** — use PATCH on that ID, not POST to `/api/collections/users`.
|
||||
- **Rules referencing fields fail if created with the collection.** Create with empty rules, then PATCH rules after schema exists.
|
||||
- **Dynamic `await import()` calls** are easy to miss — check for both `"..."` and `'...'` quoting.
|
||||
- **`login.html` may have inline `<script type="module">`** with Firebase imports — check HTML files too, not just JS.
|
||||
- **`sendPasswordResetEmail` and `updateProfile`** aren't in the base adapter — add them if the login page uses them.
|
||||
- **Nested objects** get JSON.stringified by the adapter — Firestore stores them natively. This is lossy but reversible for most data shapes.
|
||||
- **Timestamp fields** are auto-detected by field name (writeupTime, createdAt, etc.) — add to `TIMESTAMP_FIELDS` Set if the app has differently-named timestamp fields.
|
||||
- **Admin auth leaks into app pages.** If the user logged into PocketBase Admin UI (`/_/`) on the same origin, the superuser auth token persists in localStorage. On next visit to the app, `pb.authStore.isValid` returns true and `currentUser` is a superuser — the dashboard loads without login. Fix: in the adapter's `auth.currentUser` getter and `onAuthStateChanged` listener, check `model.collectionName === 'users'`. Return null for `_superusers`. This prevents admin sessions from auto-authenticating the app.
|
||||
|
||||
- **Cached user tokens also bypass login.** After fixing the admin auth leak (above), the user may still see the dashboard instead of the login page. This happens because the PocketBase SDK auto-restores **any** valid token from localStorage at initialization — including tokens from a previous `test@...` user login. The adapter's admin filtering only rejects `_superusers`; a valid `users` collection token passes right through. Fix: either (a) instruct the user to clear site data (DevTools → Application → Clear storage, or hard refresh with `Ctrl+Shift+Delete` → clear cache), or (b) add a manual sign-out button that calls `pb.authStore.clear()`. Without one of these, the user is permanently auto-authenticated until the token expires.
|
||||
|
||||
- **Dynamic imports in `shared/` resolve to the wrong directory.** After swapping Firebase CDN imports to `./pocketbase.js` in Step 5, files inside `shared/` (like `shared/header-functionality.js`) will have broken paths. `import('./pocketbase.js')` from `shared/` resolves to `shared/pocketbase.js` — but `pocketbase.js` lives in the project **root**. Fix: change to `import('../pocketbase.js')` in all `shared/` files. Also check for stale Firebase paths: `import('../firebase.js')` must become `import('../pocketbase.js')`. Search with: `grep -rn "pocketbase.js\|firebase.js" shared/`.
|
||||
|
||||
- **Nginx `Cache-Control: immutable` breaks all JS updates.** Many static-site nginx configs set `expires 30d; add_header Cache-Control "public, immutable"` on JS/CSS files. After swapping to PocketBase, browsers serve stale `${OLD}` JS files forever — even after the fix above. Change to `expires 1h; add_header Cache-Control "public, must-revalidate"` and reload nginx. Also add `?cb=` cache-busting to all redirects as a defense-in-depth measure (see `web-ui-repair` skill, section 2).
|
||||
|
||||
- **CSP on index.html silently blocks all inline fixes after migration.** Post-migration inline script guards and onclick handlers may be blocked by a Content Security Policy meta tag that lacks `'unsafe-inline'`. Diagnostic signal: inline fixes work on other pages (no CSP) but fail on index.html. Fix: add `'unsafe-inline'` to script-src. Full pattern in `web-ui-repair` skill, section 13.
|
||||
|
||||
- **Direct SQLite field manipulation breaks PocketBase schema validation.** If you modify the `_collections` table's `fields` column directly (instead of via the API), each field object in the JSON array MUST include a unique `id` property (a string like `"text3208210256"` that PocketBase generated at creation time). Without valid `id` properties, PocketBase cannot parse the field definitions and ALL create/update operations fail with a generic `"Failed to create record."` error — regardless of user permissions or data validity.\n\n **Fix:** Always use `PATCH /api/collections/{id}` via the API to add fields. This generates proper `id` values automatically. Fetch the existing collection first (`GET /api/collections/{id}`), merge new fields into the returned `fields` array, then PATCH back.\n\n- **Define ALL fields before CRUD, not just userId.**** Unlike Firestore (schemaless), PocketBase has a strict typed schema. Creating/updating a record with a field not defined in the collection schema returns **HTTP 200** but the field value is discarded — no error, no warning, just lost data. This is the #1 cause of "everything looks like it works but there's no data." **Define every field the app writes before any CRUD.** See Step 3 for the two-step schema+rules process and the `references/collection-schema-setup.py` template.
|
||||
|
||||
- **Import consolidation can accidentally drop non-Firebase imports.** When manually consolidating Firebase CDN imports into `./pocketbase.js`, adjacent non-Firebase imports (like `import { escapeHtml } from './shared/sanitize.js'`) may be deleted. After consolidation, grep each file for undeclared function references: `grep -n "escapeHtml\|showNotification\|formatCurrency" *.js shared/*.js | grep -v "^.*:import"`. If a function is used but not imported, restore the import.
|
||||
|
||||
- **Restrictive file permissions break module loading.** When copying migrated files to the deployment directory, files may have owner-only permissions (600). nginx runs as `www-data` and returns HTTP 403 for unreadable files — which causes **silent ES module chain failure** (no console error visible but no handlers attach). Fix: `find . -name "*.js" -exec chmod 644 {} +` after deployment. Verify with `curl -skI https://site.com/key-file.js | head -1` — should return 200, not 403.
|
||||
|
||||
- **Settings save/load breaks silently on PocketBase schema mismatch.** If a collection (e.g., `settings`) only has `id`, `userId`, `name` text fields but the app tries to save arbitrary key-value settings directly, PocketBase silently drops unknown fields. Fix: add a `json` field to the collection, wrap data in `{data: {...}, userId, name}`, and use query-based upsert in `setDoc`/`getDoc` (PocketBase v0.23+ requires system IDs ≥ 15 chars). Full pattern in **`references/settings-json-field-pattern.md`**.
|
||||
|
||||
## Debugging
|
||||
|
||||
See **`references/debugging-migration-bugs.md`** for common bug patterns discovered during migration validation, including:
|
||||
|
||||
| Pattern | Symptom | Fix |
|
||||
|---|---|---|
|
||||
| Dual render path filter mismatch | Completed items stay visible after status change | Sync filter between load-from-DB and re-render-local paths |
|
||||
| Silent setTimeout errors | Button click "does nothing", no console error | Try/catch inside the callback, not outside |
|
||||
| PocketBase drops undefined fields | API returns 200 but data is missing | Define every field in schema before CRUD |
|
||||
| Reference build comparison | "But the Firebase version works!" — same code bug | Use `diff` on identical functions; the Firebase version had the same bug, just unnoticed |
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
# PocketBase Adapter — Firestore Compatibility Patterns
|
||||
|
||||
Critical return-shape requirements that the PocketBase adapter MUST satisfy for multi-file Firebase apps to work without per-file changes.
|
||||
|
||||
## 1. `getDoc` — `exists()` MUST be a function
|
||||
|
||||
```javascript
|
||||
// BUG: returns boolean — every consumer calls .exists() and gets TypeError
|
||||
return { exists: true, data: () => ... };
|
||||
|
||||
// FIX: returns function — matches Firestore SDK API
|
||||
return {
|
||||
id: record.id,
|
||||
data: () => convertFromPB(record),
|
||||
exists: () => true, // <-- function, not boolean
|
||||
ref: { id: record.id }
|
||||
};
|
||||
```
|
||||
|
||||
**Why this bites you:** The Firebase SDK documents `exists()` as a method. Every consumer in the migrated app does `if (snap.exists()) { ... }`. A boolean `true` is truthy so `if (snap.exists)` evaluates `if (true)` and the branch runs. But if the record is **not found**, the boolean `false` means `if (false)` skips the branch — which happens to pass in that case. The real crash is: `snap.exists()` returning `false` vs boolean `false` being the same — but a missing `exists` key entirely kills settings pages, customer details, and any code path that calls `exists()`.
|
||||
|
||||
## 2. `getDocs` — MUST return snapshot shape, not raw array
|
||||
|
||||
```javascript
|
||||
// Firestore consumer code (ubiquitous):
|
||||
const results = await getDocs(q);
|
||||
results.docs.map(doc => ({ id: doc.id, ...doc.data() }));
|
||||
results.forEach(doc => { ... });
|
||||
if (results.empty) { ... }
|
||||
console.log(results.size); // <-- also used
|
||||
|
||||
// PocketBase adapter BUG: returns a bare array
|
||||
// → docs.map crashes (TypeError: .docs is undefined)
|
||||
// → .empty, .size are undefined
|
||||
|
||||
// FIX: createQuerySnapshot() helper
|
||||
function createQuerySnapshot(docs) {
|
||||
const docList = Array.isArray(docs) ? docs : [];
|
||||
return {
|
||||
docs: docList,
|
||||
size: docList.length,
|
||||
empty: docList.length === 0,
|
||||
forEach(callback) { docList.forEach(callback); }
|
||||
};
|
||||
}
|
||||
|
||||
async function getDocs(qOrCollectionRef) {
|
||||
// ... do query, get items from PocketBase ...
|
||||
const docs = items.map(rec => ({
|
||||
id: rec.id,
|
||||
data: () => convertFromPB(rec),
|
||||
exists: true,
|
||||
ref: { id: rec.id }
|
||||
}));
|
||||
return createQuerySnapshot(docs);
|
||||
}
|
||||
```
|
||||
|
||||
## 3. `addDoc` — MUST inject userId from sub-collection refs
|
||||
|
||||
```javascript
|
||||
// App code (Firebase sub-collection pattern):
|
||||
const servicesRef = collection(db, 'users', currentUser.uid, 'services');
|
||||
await addDoc(servicesRef, { name: 'Brake Pads', price: 199.99 });
|
||||
|
||||
// PocketBase adapter — the ref has _userId tracked but addDoc ignores it
|
||||
// BUG: record stored in 'services' collection WITHOUT userId field
|
||||
// → queries with where('userId', '==', currentUser.uid) return nothing
|
||||
|
||||
// FIX:
|
||||
async function addDoc(collRef, data) {
|
||||
const pbData = convertToPB(data);
|
||||
if (collRef._userId && !pbData.userId) {
|
||||
pbData.userId = collRef._userId; // <-- inject from ref
|
||||
}
|
||||
const record = await pb.collection(collRef._name).create(pbData);
|
||||
return { id: record.id };
|
||||
}
|
||||
```
|
||||
|
||||
## 4. `onSnapshot` — MUST pass proper snapshot shape
|
||||
|
||||
```javascript
|
||||
// Consumer code expects:
|
||||
onSnapshot(query, (snapshot) => {
|
||||
snapshot.forEach(doc => { ... }); // <-- pulls from snapshot.docs
|
||||
snapshot.docs.map(...); // <-- same
|
||||
snapshot.empty; // <-- also used
|
||||
});
|
||||
|
||||
// BUG: bare getDocs().then(docs => callback(snapshot from createQuerySnapshot))
|
||||
// must pass the full snapshot, not raw docs
|
||||
|
||||
// FIX:
|
||||
function onSnapshot(queryOrDocRef, callback) {
|
||||
if (queryOrDocRef && (queryOrDocRef._type === 'query' || queryOrDocRef._type === 'collection')) {
|
||||
getDocs(queryOrDocRef).then(snapshot => callback(snapshot));
|
||||
} else {
|
||||
getDoc(queryOrDocRef).then(doc => callback(doc));
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
|
||||
## Diagnosis: How to find these bugs
|
||||
|
||||
```javascript
|
||||
// In browser DevTools console:
|
||||
// 1) Check getDocs return shape
|
||||
const snap = await getDocs(query(collection(db, 'repairOrders'), where('userId', '==', '...')));
|
||||
console.assert(Array.isArray(snap.docs), 'getDocs.docs must be array');
|
||||
console.assert(typeof snap.empty === 'boolean', 'getDocs.empty must be boolean');
|
||||
console.assert(typeof snap.size === 'number', 'getDocs.size must be number');
|
||||
console.assert(typeof snap.forEach === 'function', 'getDocs.forEach must be function');
|
||||
|
||||
// 2) Check getDoc return shape
|
||||
const doc = await getDoc(doc(db, 'repairOrders', 'some-id'));
|
||||
console.assert(typeof doc.exists === 'function', 'getDoc.exists must be function, got ' + typeof doc.exists);
|
||||
console.assert(typeof doc.data === 'function', 'getDoc.data must be function');
|
||||
|
||||
// 3) Check addDoc injects userId
|
||||
const ref = collection(db, 'users', 'test-uid', 'services');
|
||||
const result = await addDoc(ref, { name: 'test' });
|
||||
const saved = await getDoc(doc(db, 'services', result.id));
|
||||
console.assert(saved.data().userId === 'test-uid', 'addDoc must inject userId from ref');
|
||||
```
|
||||
|
||||
## Common consumer patterns across app files
|
||||
|
||||
These patterns appear in `dashboard.js`, `appointments.js`, `customers.js`, `repair-orders.js`, `settings.js`, `quote-tab-manager.js`, `customer-lookup.js`, and `shared/data-manager.js`:
|
||||
|
||||
| Pattern | Found in | Breaks if... |
|
||||
|---|---|---|
|
||||
| `querySnapshot.docs.map(...)` | All files | `getDocs` returns raw array |
|
||||
| `querySnapshot.empty` | `repair-orders.js`, `appointments.js`, `settings.js` | Snapshot has no `.empty` |
|
||||
| `querySnapshot.size` | `dashboard.js`, `repair-orders.js`, `customers.js`, `customer-lookup.js` | Snapshot has no `.size` |
|
||||
| `querySnapshot.forEach(doc => {})` | `customers.js`, `customer-lookup.js`, `dashboard.js`, `settings.js`, `shared/data-manager.js` | Snapshot has no `.forEach` |
|
||||
| `if (docSnap.exists())` | `quote-tab-manager.js`, `repair-orders.js`, `settings.js`, `dashboard.js`, `invoice-manager.js`, `customers.js` | `exists` is boolean not function |
|
||||
| `collection(db, 'users', uid, '...')` | All files (sub-collection pattern) | `addDoc` doesn't inject `userId` |
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
#!/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!")
|
||||
@@ -0,0 +1,57 @@
|
||||
# PocketBase Collection Creation Script
|
||||
# Create all collections needed for a Firebase migration.
|
||||
# Run from the Docker host: python3 create_collections.py
|
||||
|
||||
import urllib.request
|
||||
import json
|
||||
|
||||
BASE = "http://127.0.0.1:8091"
|
||||
EMAIL = "admin@example.com"
|
||||
PASS = "your-admin-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)
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode()
|
||||
if "exists" in body.lower() or "unique" in body.lower():
|
||||
return None # Already exists, OK
|
||||
print(f" ERROR {method} {path}: HTTP {e.code} — {body[:300]}")
|
||||
return None
|
||||
|
||||
# Auth
|
||||
auth = api("POST", "/api/collections/_superusers/auth-with-password",
|
||||
{"identity": EMAIL, "password": PASS})
|
||||
token = auth["token"]
|
||||
|
||||
# Define your collections — add our app's collections
|
||||
COLLECTIONS = [
|
||||
("repairOrders", [{"name": "userId", "type": "text", "required": True}]),
|
||||
("appointments", [{"name": "userId", "type": "text", "required": True}]),
|
||||
("quotes", [{"name": "userId", "type": "text", "required": True}]),
|
||||
("tasks", [{"name": "userId", "type": "text", "required": True}]),
|
||||
("services", [{"name": "userId", "type": "text", "required": True}]),
|
||||
("customers", [{"name": "userId", "type": "text", "required": True}]),
|
||||
("settings", [
|
||||
{"name": "userId", "type": "text", "required": True},
|
||||
{"name": "name", "type": "text", "required": True},
|
||||
]),
|
||||
]
|
||||
|
||||
# Create collections (empty rules first — rules fail if they reference fields before schema exists)
|
||||
for name, schema in COLLECTIONS:
|
||||
result = api("POST", "/api/collections", {
|
||||
"name": name, "type": "base", "schema": schema,
|
||||
"listRule": "", "viewRule": "", "createRule": "",
|
||||
"updateRule": "", "deleteRule": ""
|
||||
}, token)
|
||||
if result: print(f"Created: {name}")
|
||||
else: print(f"Skipped: {name} (exists or error)")
|
||||
|
||||
print("Done!")
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
# Debugging Firebase → PocketBase Migration Bugs
|
||||
|
||||
Common bug patterns discovered during migration validation, with root cause analysis and fix strategies.
|
||||
|
||||
## 1. Dual Render Path Inconsistency
|
||||
|
||||
After completing a repair order, it remains visible in the Active tab despite `workStatus` being set to `'completed'`.
|
||||
|
||||
### Root Cause
|
||||
|
||||
The app has **two render paths** that apply different filters:
|
||||
|
||||
```
|
||||
loadRepairOrders() renderActiveRepairOrders()
|
||||
│ │
|
||||
│ filter: workStatus !== │ NO FILTER — passes entire
|
||||
│ 'completed' && status !== │ repairOrders array to
|
||||
│ 'completed' │ renderRepairOrders()
|
||||
│ │
|
||||
▼ ▼
|
||||
renderRepairOrders(filtered) renderRepairOrders(all)
|
||||
```
|
||||
|
||||
- **`loadRepairOrders()`** (called on page load) fetches from DB, then filters out completed orders at lines ~2276-2278
|
||||
- **`renderActiveRepairOrders()`** (called after every UI mutation) renders the local `repairOrders` array **without any filter**
|
||||
|
||||
When `handleFinancialCompletion()` sets `workStatus = 'completed'` and calls `renderActiveRepairOrders()`, the completed RO stays visible because the re-render path doesn't filter.
|
||||
|
||||
### Fix
|
||||
|
||||
```javascript
|
||||
function renderActiveRepairOrders() {
|
||||
const activeOrders = repairOrders.filter(ro => {
|
||||
return ro.workStatus !== 'completed' && ro.status !== 'completed';
|
||||
});
|
||||
renderRepairOrders(activeOrders);
|
||||
}
|
||||
```
|
||||
|
||||
This uses the same filter predicate as `loadRepairOrders()`.
|
||||
|
||||
### How to detect
|
||||
|
||||
Search for all functions that render the same list. The app typically has:
|
||||
- A **load** function (fetches from DB, applies filters, assigns to global `repairOrders`, calls render)
|
||||
- A **render** function (re-renders from the global array, used for reactive UI updates)
|
||||
|
||||
If the load function filters but the render function doesn't, this bug exists. To verify:
|
||||
|
||||
```
|
||||
grep -n "function renderActiveRepairOrders\|function loadRepairOrders\|function renderRepairOrders" *.js
|
||||
```
|
||||
|
||||
Then check:
|
||||
1. Does `loadRepairOrders()` filter out completed items before calling `renderRepairOrders()`?
|
||||
2. Does `renderActiveRepairOrders()` pass the raw array or a filtered one?
|
||||
|
||||
### Search pattern
|
||||
|
||||
```
|
||||
grep -n "function.*render.*Orders\|function.*load.*Orders\|\.filter.*completed" repair-orders.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Silent Errors in setTimeout / Asynchronous Callbacks
|
||||
|
||||
A DOM element is missing from the HTML, causing a `TypeError` that crashes silently. The UI appears to "do nothing" when a button is clicked, with no console error visible.
|
||||
|
||||
### Root Cause
|
||||
|
||||
```javascript
|
||||
// Outer try/catch:
|
||||
try {
|
||||
// ... sync code ...
|
||||
setTimeout(() => {
|
||||
// This code runs AFTER the try/catch scope has exited
|
||||
document.getElementById('missing-element').textContent = value;
|
||||
// TypeError here is UNCAUGHT — it fires in the event loop tick
|
||||
// after the try/catch already returned
|
||||
}, 100);
|
||||
return; // <-- try/catch scope exits
|
||||
} catch (error) {
|
||||
// Never reaches here for the setTimeout error
|
||||
console.error('Error:', error);
|
||||
}
|
||||
```
|
||||
|
||||
The `try/catch` only protects the code inside its lexical scope. `setTimeout` (and `Promise.then`, `requestAnimationFrame`, `setInterval`) execute in a **new execution context** — the outer `try/catch` is already gone.
|
||||
|
||||
### How to find
|
||||
|
||||
1. Look for DOM operations inside `setTimeout()`, `requestAnimationFrame()`, or `.then()` callbacks
|
||||
2. Check if they're inside a `try/catch` — if the `try/catch` wraps the `setTimeout` call (not the callback body), the callback is unprotected
|
||||
3. The ONLY way to catch these is a `try/catch` **inside** the callback, or `window.onerror` / `window.addEventListener('unhandledrejection', ...)`
|
||||
|
||||
### Fix
|
||||
|
||||
```javascript
|
||||
// Option A: try/catch INSIDE the callback
|
||||
setTimeout(() => {
|
||||
try {
|
||||
document.getElementById('missing-element').textContent = value;
|
||||
} catch (innerError) {
|
||||
console.error('setTimeout error:', innerError);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Option B: Replace setTimeout with synchronous code when possible
|
||||
// (Only if the delay isn't needed for DOM readiness)
|
||||
```
|
||||
|
||||
### Detection script
|
||||
|
||||
Run this in browser DevTools to detect pattern:
|
||||
|
||||
```javascript
|
||||
// Check all JS files for setTimeout with DOM ops outside try/catch
|
||||
document.querySelectorAll('script[src]').forEach(s => {
|
||||
fetch(s.src).then(r => r.text()).then(code => {
|
||||
const matches = code.match(/setTimeout\([^)]*getElementById[^)]*\)/g);
|
||||
if (matches) console.log(`${s.src}: ${matches.length} vulnerable setTimeout(s)`);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. "Looks like it works but data is missing" — PocketBase Silently Drops Undefined Fields
|
||||
|
||||
After a successful API call (HTTP 200), the saved record is missing fields the app wrote.
|
||||
|
||||
### Root Cause
|
||||
|
||||
PocketBase is **strictly typed** — fields not defined in the collection schema are **silently dropped** on write. Firestore is schemaless, so any field in the JavaScript object gets stored.
|
||||
|
||||
### How to find missing fields systematically
|
||||
|
||||
```bash
|
||||
# 1. Get the PocketBase collection schema field names
|
||||
PB_FIELDS=$(curl -s http://127.0.0.1:8091/api/collections/repairOrders | \
|
||||
python3 -c "import sys,json; print('\n'.join(f['name'] for f in json.load(sys.stdin).get('fields',[])))")
|
||||
|
||||
# 2. Get all field names the JavaScript app writes (from CRUD operations)
|
||||
JS_FIELDS=$(grep -ohP '\b(customerName|workStatus|roNumber|vehicleInfo|vin|mileage|services|estimatedTime|promisedTime|lastModified|completedTime|financial|writeupTime|status|userId|customerPhone|deviceModel|deviceType|issue|priority|technician|notes|price|customerId|quoteId|invoiceId|lastActivity|createdAt|updatedAt)\b' repair-orders.js | sort -u)
|
||||
|
||||
# 3. Compare — fields in JS that are NOT in PB schema
|
||||
echo "Missing from PB schema:"
|
||||
for f in $JS_FIELDS; do
|
||||
echo "$PB_FIELDS" | grep -q "$f" || echo " - $f"
|
||||
done
|
||||
```
|
||||
|
||||
Commonly missed fields in a shop management app migration:
|
||||
| Field | Type | Where It's Written |
|
||||
|---|---|---|
|
||||
| `workStatus` | text | `updateRepairOrderWorkStatus()`, `createNewRepairOrder()` |
|
||||
| `financial` | json | `handleFinancialCompletion()` — nested object with cpTotal, cpCost, etc. |
|
||||
| `roNumber` | text | `createNewRepairOrder()` — auto-generated RO-XXXXXXX |
|
||||
| `vehicleInfo` | text | Repair order create form & customer data |
|
||||
| `vin` | text | Repair order create form |
|
||||
| `mileage` | text | Repair order create form |
|
||||
| `services` | text | Repair order services field (comma-separated) |
|
||||
| `estimatedTime` | text | Repair order promised time calculation |
|
||||
| `promisedTime` | text | Created from estimatedTime + currentTime |
|
||||
| `lastModified` | text | Updated on every mutation |
|
||||
| `completedTime` | text | Set when workStatus becomes 'completed' |
|
||||
|
||||
### Symptom: "Completing an RO doesn't stick"
|
||||
|
||||
If the user reports: "I marked it complete, it showed completed, but after refresh it's back in Active" — this is ALWAYS a schema mismatch. The app updates the local JavaScript array (shows correct UI), but the PocketBase write silently drops `workStatus` and `financial` because those fields aren't in the collection schema. On page reload, `loadRepairOrders()` fetches from PB and the RO has its original `workStatus` (missing = undefined), so it passes the "not completed" filter.
|
||||
|
||||
This is subtly different from the Dual Render Path bug (which happens BEFORE refresh — RO stays in Active tab immediately). The schema mismatch bug: RO correctly disappears from Active tab after completion, but reappears on next page load.
|
||||
|
||||
### How to find
|
||||
|
||||
---
|
||||
|
||||
## 4. Reference Build Comparison Technique
|
||||
|
||||
When a Firebase build works but the PocketBase port doesn't, compare the same function in both:
|
||||
|
||||
```bash
|
||||
# Find where functions live in both builds
|
||||
grep -n "updateRepairOrderWorkStatus\|handleFinancialCompletion" \
|
||||
/path/to/firebase/repair-orders.js \
|
||||
/path/to/pocketbase/repair-orders.js
|
||||
|
||||
# Read and diff
|
||||
diff <(sed -n '1747,1823p' /path/to/firebase/repair-orders.js) \
|
||||
<(sed -n '1747,1823p' /path/to/pocketbase/repair-orders.js)
|
||||
```
|
||||
|
||||
If the functions are **identical**, the bug is not in the PocketBase adapter — it's a pre-existing bug in the app code that was hidden by Firebase behavior (realtime listeners auto-fixing the UI, different execution timing, etc.). In this case, the Firebase build had the same `renderActiveRepairOrders` filter bug, but it may have appeared to work because:
|
||||
- Users refreshed the page (which calls `loadRepairOrders()` with the filter)
|
||||
- The issue existed but was never noticed because the financial completion was never tested end-to-end
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
# PocketBase Settings Save/Load with JSON Fields
|
||||
|
||||
## Problem
|
||||
|
||||
PocketBase collections with a fixed schema (e.g., `id`, `userId`, `name` text fields) can't store arbitrary key-value settings like `{darkMode: true, taxRate: 9.25, ...}`. Every field written to a PocketBase record MUST be defined in the collection schema, or it's **silently dropped** (HTTP 200, data lost).
|
||||
|
||||
Additionally, **PocketBase v0.23+** enforces a minimum 15-character ID with format validation (lowercase alphanumeric only). Trying to `setDoc` with an ID like `appSettings` (11 chars) fails silently — the adapter's `update('appSettings', data)` → 404, fallback `create({...data, id:'appSettings'})` → 400 `validation_invalid_format`.
|
||||
|
||||
## The Fix: Three Layers
|
||||
|
||||
### 1. Add a `json` field to the collection
|
||||
|
||||
```python
|
||||
# PATCH the collection to add a JSON field
|
||||
api("PATCH", f"/api/collections/settings", {
|
||||
"fields": [
|
||||
...existing fields...,
|
||||
{"id": "json_data", "name": "data", "type": "json", "required": False, "system": False}
|
||||
],
|
||||
...keep existing rules...
|
||||
}, token)
|
||||
```
|
||||
|
||||
The `json` field type accepts arbitrary objects — PocketBase stores and retrieves them natively.
|
||||
|
||||
### 2. Fix `setDoc` in the adapter — query-based upsert
|
||||
|
||||
Don't try to set PocketBase's system `id` field to custom values. Instead, query by `name + userId` to find the record, then update or create:
|
||||
|
||||
```javascript
|
||||
function setDoc(docRef, data) {
|
||||
const collName = docRef._collection;
|
||||
const docId = docRef._id;
|
||||
const pbData = convertToPB(data);
|
||||
const userId = docRef._userId || '';
|
||||
|
||||
// Find existing record by name + userId
|
||||
return pb.collection(collName).getFullList({
|
||||
filter: `name = "${docId}"${userId ? ` && userId = "${userId}"` : ''}`,
|
||||
requestKey: null
|
||||
}).then(records => {
|
||||
if (records.length > 0) {
|
||||
return pb.collection(collName).update(records[0].id, pbData);
|
||||
} else {
|
||||
return pb.collection(collName).create(pbData);
|
||||
}
|
||||
}).then(rec => ({ id: rec.id }));
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Fix `getDoc` — fallback to name-based lookup
|
||||
|
||||
```javascript
|
||||
async function getDoc(docRef) {
|
||||
const collName = docRef._collection;
|
||||
const docId = docRef._id;
|
||||
const userId = docRef._userId || '';
|
||||
|
||||
try {
|
||||
// Try direct ID lookup first (for backward compat)
|
||||
let record = await pb.collection(collName).getOne(docId);
|
||||
return { id: record.id, data: () => convertFromPB(record), exists: () => true, ref: { id: record.id } };
|
||||
} catch (err) {
|
||||
// Fallback: query by name + userId
|
||||
try {
|
||||
const filter = `name = "${docId}"${userId ? ` && userId = "${userId}"` : ''}`;
|
||||
const records = await pb.collection(collName).getFullList({ filter: filter, requestKey: null });
|
||||
if (records.length > 0) {
|
||||
const record = records[0];
|
||||
return { id: record.id, data: () => convertFromPB(record), exists: () => true, ref: { id: record.id } };
|
||||
}
|
||||
} catch (e2) {}
|
||||
return { exists: () => false, data: () => null, ref: { id: docId } };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Fix save/load callers — wrap data in `data` json field
|
||||
|
||||
**Save:**
|
||||
```javascript
|
||||
// BEFORE (fails — PocketBase drops unknown fields)
|
||||
await setDoc(userSettingsRef, { ...appSettings, lastUpdated: new Date().toISOString() });
|
||||
|
||||
// AFTER (works — settings go into the 'data' json field)
|
||||
await setDoc(userSettingsRef, {
|
||||
data: { ...appSettings, lastUpdated: new Date().toISOString() },
|
||||
userId: currentUser.uid,
|
||||
name: 'appSettings'
|
||||
});
|
||||
```
|
||||
|
||||
**Load:**
|
||||
```javascript
|
||||
// BEFORE (reads top-level fields — gets nothing useful)
|
||||
const firebaseSettings = settingsDoc.data();
|
||||
appSettings = { ...appSettings, ...firebaseSettings };
|
||||
|
||||
// AFTER (reads from 'data' json field)
|
||||
const record = settingsDoc.data();
|
||||
if (record && record.data) {
|
||||
const { lastUpdated, ...cleanSettings } = record.data;
|
||||
appSettings = { ...appSettings, ...cleanSettings };
|
||||
}
|
||||
```
|
||||
|
||||
## Full Pattern Checklist
|
||||
|
||||
When settings aren't persisting:
|
||||
- [ ] Does the collection have a `json` field for arbitrary data?
|
||||
- [ ] Is `setDoc` using query-based upsert (not trying to set system IDs)?
|
||||
- [ ] Are all save callers wrapping data in `{data: {...}, userId, name}`?
|
||||
- [ ] Are all load callers reading from `record.data` instead of top-level fields?
|
||||
- [ ] Does every save path include `userId` and `name` for collection rule compliance?
|
||||
@@ -0,0 +1,261 @@
|
||||
// pocketbase.js — Firebase-compatible PocketBase adapter
|
||||
// Replace firebase.js with this file. All Firebase-using files work unchanged.
|
||||
//
|
||||
// Swap imports:
|
||||
// find . -name "*.js" -exec sed -i 's|from "./firebase.js"|from "./pocketbase.js"|g' {} +
|
||||
// find . -name "*.js" -exec sed -i 's|from "https://www\.gstatic\.com/firebasejs/...|from "./pocketbase.js"|g' {} +
|
||||
//
|
||||
// See firebase-to-pocketbase-migration skill for full migration guide.
|
||||
|
||||
import PocketBase from "https://cdn.jsdelivr.net/npm/pocketbase@0.21.5/dist/pocketbase.es.mjs";
|
||||
|
||||
// ─── PocketBase Init ───────────────────────────────────────────────
|
||||
const BASE_URL = window.location.origin;
|
||||
const pb = new PocketBase(BASE_URL);
|
||||
|
||||
// ─── Auth (Firebase-compatible surface) ────────────────────────────
|
||||
const auth = {
|
||||
get currentUser() {
|
||||
if (!pb.authStore.isValid) return null;
|
||||
const model = pb.authStore.model;
|
||||
// Only return users from 'users' collection, not superusers/admins
|
||||
if (model.collectionName !== 'users') return null;
|
||||
return {
|
||||
uid: model.id,
|
||||
email: model.email,
|
||||
displayName: model.name || null
|
||||
};
|
||||
},
|
||||
|
||||
onAuthStateChanged(callback) {
|
||||
// Fire initial state synchronously (like Firebase does)
|
||||
const current = this.currentUser;
|
||||
callback(current);
|
||||
|
||||
// Listen for future auth changes
|
||||
pb.authStore.onChange((token, model) => {
|
||||
const user = (model && model.collectionName === 'users') ? {
|
||||
uid: model.id,
|
||||
email: model.email,
|
||||
displayName: model.name || null
|
||||
} : null;
|
||||
callback(user);
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Standalone Auth Functions ─────────────────────────────────────
|
||||
function onAuthStateChanged(authRef, callback) { return auth.onAuthStateChanged(callback); }
|
||||
|
||||
async function signInWithEmailAndPassword(a, email, password) {
|
||||
await pb.collection('users').authWithPassword(email, password);
|
||||
return { user: auth.currentUser };
|
||||
}
|
||||
|
||||
async function createUserWithEmailAndPassword(a, email, password) {
|
||||
await pb.collection('users').create({
|
||||
email, password, passwordConfirm: password, emailVisibility: true
|
||||
});
|
||||
await pb.collection('users').authWithPassword(email, password);
|
||||
return { user: auth.currentUser };
|
||||
}
|
||||
|
||||
async function signOut(a) { pb.authStore.clear(); }
|
||||
|
||||
async function sendPasswordResetEmail(a, email) {
|
||||
await pb.collection('users').requestPasswordReset(email);
|
||||
}
|
||||
|
||||
async function updateProfile(user, data) {
|
||||
const updateData = {};
|
||||
if (data.displayName) updateData.name = data.displayName;
|
||||
await pb.collection('users').update(user.uid, updateData);
|
||||
return { ...user, displayName: data.displayName || user.displayName };
|
||||
}
|
||||
|
||||
async function updatePassword(user, newPassword) {
|
||||
await pb.collection('users').update(user.uid, {
|
||||
password: newPassword, passwordConfirm: newPassword
|
||||
});
|
||||
}
|
||||
|
||||
async function reauthenticateWithCredential(user, credential) {
|
||||
await pb.collection('users').authWithPassword(credential._email, credential._password);
|
||||
}
|
||||
|
||||
const EmailAuthProvider = {
|
||||
credential: (email, password) => ({ _email: email, _password: password })
|
||||
};
|
||||
|
||||
// ─── Firestore-compatible DB Surface ───────────────────────────────
|
||||
const db = {};
|
||||
|
||||
function collection(parent, name, ...subPath) {
|
||||
if (subPath.length === 0) {
|
||||
return { _type: 'collection', _name: name, _path: [name] };
|
||||
}
|
||||
const collectionName = subPath[subPath.length - 1];
|
||||
return { _type: 'collection', _name: collectionName, _path: [name, ...subPath], _userId: subPath[0] };
|
||||
}
|
||||
|
||||
function doc(db, ...path) {
|
||||
if (path.length === 2) return { _type: 'doc', _collection: path[0], _id: path[1] };
|
||||
return { _type: 'doc', _collection: path[path.length - 2], _id: path[path.length - 1], _userId: path[1] };
|
||||
}
|
||||
|
||||
// ─── Query Builder ─────────────────────────────────────────────────
|
||||
function where(field, op, value) { return { _type: 'where', field, op, value }; }
|
||||
function orderBy(field, direction) { return { _type: 'orderBy', field, direction }; }
|
||||
function limit(n) { return { _type: 'limit', value: n }; }
|
||||
function query(collRef, ...constraints) { return { _type: 'query', collection: collRef, constraints }; }
|
||||
|
||||
// ─── PocketBase Filter Builder ─────────────────────────────────────
|
||||
const OP_MAP = {'==': '=', '!=': '!=', '>': '>', '<': '<', '>=': '>=', '<=': '<=', 'in': '?=', 'not-in': '?!='};
|
||||
|
||||
function buildFilter(constraints) {
|
||||
const parts = [];
|
||||
for (const c of constraints) {
|
||||
if (c._type !== 'where') continue;
|
||||
const pbOp = OP_MAP[c.op] || '=';
|
||||
if (pbOp === '?=' && Array.isArray(c.value)) {
|
||||
const orParts = c.value.map(v => typeof v === 'string' ? `${c.field} ?= "${v}"` : `${c.field} ?= ${v}`);
|
||||
parts.push(`(${orParts.join(' || ')})`);
|
||||
} else if (pbOp === '?=') {
|
||||
const v = typeof c.value === 'string' ? `"${c.value}"` : c.value;
|
||||
parts.push(`${c.field} ${pbOp} ${v}`);
|
||||
} else {
|
||||
const v = typeof c.value === 'string' ? `"${c.value}"` : c.value;
|
||||
parts.push(`${c.field} ${pbOp} ${v}`);
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? parts.join(' && ') : '';
|
||||
}
|
||||
|
||||
function buildSort(constraints) {
|
||||
return constraints.filter(c => c._type === 'orderBy').map(c => (c.direction === 'desc' ? '-' : '+') + c.field);
|
||||
}
|
||||
|
||||
// ─── CRUD Operations ───────────────────────────────────────────────
|
||||
async function getDocs(q) {
|
||||
const collName = q.collection._name;
|
||||
const filter = buildFilter(q.constraints);
|
||||
const sort = buildSort(q.constraints);
|
||||
const limitVal = q.constraints.find(c => c._type === 'limit');
|
||||
const options = { requestKey: null };
|
||||
if (filter) options.filter = filter;
|
||||
if (sort.length > 0) options.sort = sort.join(',');
|
||||
if (limitVal) options.perPage = limitVal.value;
|
||||
|
||||
try {
|
||||
const records = limitVal
|
||||
? await pb.collection(collName).getList(1, limitVal.value, options)
|
||||
: await pb.collection(collName).getFullList(options);
|
||||
const items = limitVal ? records.items : records;
|
||||
|
||||
const docs = items.map(rec => ({
|
||||
id: rec.id, data: () => convertFromPB(rec), exists: true, ref: { id: rec.id }
|
||||
}));
|
||||
docs.forEach = function(cb) { this.forEach(cb); };
|
||||
return docs;
|
||||
} catch (err) {
|
||||
console.warn(`PocketBase getDocs(${collName}):`, err.message);
|
||||
const empty = []; empty.forEach = function(cb) {}; return empty;
|
||||
}
|
||||
}
|
||||
|
||||
async function addDoc(collRef, data) {
|
||||
const record = await pb.collection(collRef._name).create(convertToPB(data));
|
||||
return { id: record.id };
|
||||
}
|
||||
|
||||
async function updateDoc(docRef, data) {
|
||||
await pb.collection(docRef._collection).update(docRef._id, convertToPB(data));
|
||||
}
|
||||
|
||||
async function deleteDoc(docRef) {
|
||||
await pb.collection(docRef._collection).delete(docRef._id);
|
||||
}
|
||||
|
||||
async function getDoc(docRef) {
|
||||
try {
|
||||
const record = await pb.collection(docRef._collection).getOne(docRef._id);
|
||||
return { id: record.id, data: () => convertFromPB(record), exists: true };
|
||||
} catch (err) { return { exists: false, data: () => null }; }
|
||||
}
|
||||
|
||||
function setDoc(docRef, data) {
|
||||
const pbData = convertToPB(data);
|
||||
return pb.collection(docRef._collection).update(docRef._id, pbData).catch(() =>
|
||||
pb.collection(docRef._collection).create({ ...pbData, id: docRef._id })
|
||||
).then(rec => ({ id: rec.id }));
|
||||
}
|
||||
|
||||
function onSnapshot(queryOrDocRef, callback) {
|
||||
if (queryOrDocRef._type === 'query') {
|
||||
getDocs(queryOrDocRef).then(docs => callback({ docs, forEach: cb => docs.forEach(cb) }));
|
||||
} else {
|
||||
getDoc(queryOrDocRef).then(doc => callback(doc));
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
|
||||
// ─── Timestamps ────────────────────────────────────────────────────
|
||||
function serverTimestamp() { return new Date().toISOString(); }
|
||||
|
||||
class Timestamp {
|
||||
constructor(date) { this._date = date || new Date(); }
|
||||
toDate() { return this._date; }
|
||||
toMillis() { return this._date.getTime(); }
|
||||
}
|
||||
|
||||
// ─── Data Conversion ───────────────────────────────────────────────
|
||||
// Add your app's timestamp field names here
|
||||
const TIMESTAMP_FIELDS = new Set([
|
||||
'writeupTime', 'createdAt', 'updatedAt', 'lastUpdated',
|
||||
'appointmentDateTime', 'completionTime', 'lastActivity',
|
||||
'timestamp', 'dateCreated', 'startDate', 'endDate', 'deadline'
|
||||
]);
|
||||
|
||||
function convertToPB(data) {
|
||||
const result = {};
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (value === undefined) continue;
|
||||
if (value && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
|
||||
result[key] = JSON.stringify(value);
|
||||
} else if (value instanceof Date) {
|
||||
result[key] = value.toISOString();
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function convertFromPB(record) {
|
||||
const data = { ...record };
|
||||
for (const field of TIMESTAMP_FIELDS) {
|
||||
if (typeof data[field] === 'string' && data[field].match(/^\d{4}-\d{2}-\d{2}T/)) {
|
||||
data[field] = new Timestamp(new Date(data[field]));
|
||||
}
|
||||
}
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (typeof value === 'string' && (value.startsWith('{') || value.startsWith('['))) {
|
||||
try { const p = JSON.parse(value); if (typeof p === 'object') data[key] = p; } catch (e) {}
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// ─── Exports ───────────────────────────────────────────────────────
|
||||
export { auth, db };
|
||||
export {
|
||||
signInWithEmailAndPassword, createUserWithEmailAndPassword, signOut,
|
||||
sendPasswordResetEmail, updateProfile, onAuthStateChanged,
|
||||
updatePassword, reauthenticateWithCredential, EmailAuthProvider
|
||||
};
|
||||
export {
|
||||
collection, doc, query, where, orderBy, limit,
|
||||
getDocs, getDoc, addDoc, updateDoc, deleteDoc, setDoc, onSnapshot,
|
||||
serverTimestamp, Timestamp
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
# Same-Origin nginx Config for PocketBase Apps
|
||||
# Serves static files AND proxies /api/* to PocketBase from one port.
|
||||
# The app uses `new PocketBase(window.location.origin)` — zero CORS.
|
||||
#
|
||||
# Place in /etc/nginx/sites-available/<app-name>
|
||||
# Symlink: ln -s /etc/nginx/sites-available/<app-name> /etc/nginx/sites-enabled/
|
||||
# Test: nginx -t && systemctl reload nginx
|
||||
|
||||
server {
|
||||
listen <PORT> ssl;
|
||||
server_name <DOMAIN>;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/<DOMAIN>/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/<DOMAIN>/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# Static app files
|
||||
root /path/to/your/app;
|
||||
index index.html;
|
||||
|
||||
# PocketBase API (same origin = no CORS needed)
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:<PB_PORT>;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# WebSocket for PocketBase realtime
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
# PocketBase admin UI
|
||||
location /_/ {
|
||||
proxy_pass http://127.0.0.1:<PB_PORT>;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# SPA fallback: all paths → index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Static file caching (30 days for hashed assets)
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
client_max_body_size 50m;
|
||||
}
|
||||
Reference in New Issue
Block a user