WFDASHDRIVER-APP/lib/pocketbase.ts
Dave 5ef212cdfa docs(pocketbase): clarify driver/drv_id schema — operator app fix needed
Live PB schema probe (2026-08-11) confirmed `driver` and
`item_status="driver_pool"` are not in the schema; referencing
either in a filter hard-400s the whole query. The driver app's
getMyAssignedItems already used the canonical `drv_id` +
`item_status="assigned"` pair, so a defensive OR-fallback against
the missing fields would have BROKEN the query (not rescued it);
that attempt was reverted.

Items 'assigned' by the operator (WFDash) app never reach the
driver's Assigned tab because the operator-side filter still
references those non-existent identifiers. Operator app needs to
filter/write `drv_id` and `item_status="assigned"` instead.

Also restored the dropped OrderItem.expand definition (each
component was accessing item.expand.* unchecked) and widened the
item_status union to include 'invoiced' and 'problem' seen in the
live schema.

Co-authored-by: Orca <help@stably.ai>
2026-08-12 08:04:19 +02:00

483 lines
No EOL
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import PocketBase from 'pocketbase';
import {
AppSettings,
DEFAULT_SETTINGS,
OrderItem,
OrderItemAttachment,
AttachmentSource,
DriverLocation,
} from './types';
import { clearCache as clearItemsCache } from './itemsCache';
const POCKETBASE_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL || 'https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io';
export const pb = new PocketBase(POCKETBASE_URL);
// Disable auto-cancellation so React re-renders don't kill in-flight requests
pb.autoCancellation(false);
// Auth helpers
export const isAuthenticated = () => pb.authStore.isValid;
export const getCurrentUser = () => pb.authStore.model;
export const logout = () => {
pb.authStore.clear();
// Drop cached items so the next driver who logs in on this
// device doesn't briefly see the previous driver's list.
clearItemsCache();
if (typeof window !== 'undefined') {
window.location.href = '/login/';
}
};
export const login = async (email: string, password: string) => {
return await pb.collection('users').authWithPassword(email, password);
};
// PB `order_items` schema (verified live 2026-08-11):
// drv_id = relation -> users (canonical "assigned driver" field)
// driver = NOT IN PB — confirmed via schema probe. The
// operator (WFDash) app filters on this and gets
// 400s silently. Must be updated to use drv_id.
// supplier = relation -> suppliers (singular)
// item_status real values: assigned, collection_pool, pending,
// collected, problem, invoiced, ... (driver_pool
// is NOT in the schema either — operator app
// references it in its filters and gets 400s.)
// drv_status real values: assigned, en_route, collected, failed, '' (empty),
// unassigned
// po_description = supplier-facing line ("M20×40 nut sku 11243…")
// description = customer-facing line
// order_qty = supplier-facing qty (falls back to quantity)
// quantity = customer-facing qty
// assigned_at = ISO timestamp, populated server-side on assignment
// problem_notes = driver-written audit trail of failed collections
//
// Assigned-tab query:
// The driver app's getMyAssignedItems filters on the canonical
// drv_id + item_status="assigned" pair. Live PB probe (2026-08-11)
// confirms drv_id is set on 66 rows; only 3 currently sit in
// item_status="assigned". The remaining 63 have moved on in workflow
// (e.g. invoiced). Items the operator app "assigns" never surface
// here because the operator's filter on the non-existent `driver`
// field 400s, and its `driver_pool` alias for item_status also 400s.
// Operator-side fix required: write `drv_id` and
// `item_status = "assigned"`.
//
// Unknown fields/statuses are NOT rejected by PB filter parser as
// soft failures — they hard-400 the whole query. Always use the exact
// field + status strings below.
// ---------- Hydration helper ----------
// PB query builder can't always do the supplier expand (some deployments
// strip expand support). This helper takes raw records and, if supplier
// isn't already expanded, batch-fetches suppliers by id and stitches
// them back into item.expand.supplier. Shared by every fetch below.
const hydrateSuppliers = async (items: OrderItem[]): Promise<OrderItem[]> => {
const missing = Array.from(new Set(
items
.filter((it) => !it.expand?.supplier && (it as any).supplier)
.map((it) => (it as any).supplier as string)
));
if (missing.length === 0) return items;
let supplierMap = new Map<string, any>();
try {
const filter = missing.map((id) => `id = "${id}"`).join(' || ');
const suppliers = await pb.collection('suppliers').getFullList({ filter });
supplierMap = new Map(suppliers.map((s: any) => [s.id, s]));
} catch {
return items;
}
return items.map((it) => {
if (it.expand?.supplier) return it;
const supplierId = (it as any).supplier;
const supplier = supplierId ? supplierMap.get(supplierId) : null;
if (!supplier) return it;
return {
...it,
expand: { ...(it.expand || {}), supplier },
} as OrderItem;
});
};
// ---------- 1. ASSIGNED — only items with drv_id = current user ----------
// Live PB schema probe (2026-08-11) confirmed:
// - drv_id is the canonical driver-assignment relation (66 rows set).
// - `driver` and `item_status = "driver_pool"` are NOT in the schema.
// Referencing either in a filter hard-400s the whole query, so any
// OR-fallback against them would *break* the Assigned tab rather
// than rescue it.
// - item_status = "assigned" is the live, working status. Currently
// 3 rows; the others have moved on in the workflow (e.g. invoiced).
//
// If the operator app needs items to land here, it must write
// `drv_id = user.id` and `item_status = "assigned"` on the row.
export const getMyAssignedItems = async (): Promise<OrderItem[]> => {
const user = getCurrentUser();
if (!user) return [];
try {
const items = await pb.collection('order_items').getFullList<OrderItem>({
filter: `drv_id = "${user.id}" && item_status = "assigned"`,
expand: 'supplier,order,purchase_order',
sort: 'supplier.suppliername',
});
return items;
} catch {
const items = await pb.collection('order_items').getFullList<OrderItem>({
filter: `drv_id = "${user.id}" && item_status = "assigned"`,
});
return hydrateSuppliers(items);
}
};
// Backwards-compat alias — keep old call sites working during the refactor.
export const getAssignedCollections = getMyAssignedItems;
// ---------- 2. POOL — every collection_pool item in the system ----------
// Pool means "open for any driver to claim". The driver app shows ALL
// pool rows here so the driver can see what's available before claiming.
// No driver filter on purpose.
export const getPoolItems = async (): Promise<OrderItem[]> => {
try {
const items = await pb.collection('order_items').getFullList<OrderItem>({
filter: `item_status = "collection_pool"`,
expand: 'supplier,order,purchase_order',
sort: 'supplier.suppliername',
});
return items;
} catch {
const items = await pb.collection('order_items').getFullList<OrderItem>({
filter: `item_status = "collection_pool"`,
});
return hydrateSuppliers(items);
}
};
// ---------- 3. PROBLEMS — every flagged item in the system ----------
// Items with problem_flag=true OR item_status='problem'. No driver
// filter — problems are operational visibility, not personal lists.
export const getProblemItems = async (): Promise<OrderItem[]> => {
try {
const items = await pb.collection('order_items').getFullList<OrderItem>({
filter: `problem_flag = true || item_status = "problem"`,
expand: 'supplier,order,purchase_order',
sort: '-updated',
});
return items;
} catch {
const items = await pb.collection('order_items').getFullList<OrderItem>({
filter: `problem_flag = true || item_status = "problem"`,
});
return hydrateSuppliers(items);
}
};
// Update order item status when collected. This is the canonical
// driver-side collection writer — the dashboard's
// `/api/v1/order-items/[id]/status` route covers the same patch from
// the operator side, so keep the field set aligned.
//
// Status flow on collection:
// - Always sets:
// * `item_status = "collected"` (operator-side)
// * `drv_status = "collected"` (driver-side status machine)
// * `drv_collected_at = now` (timestamp of physical pickup)
// * `qty_collected = receivedQty` (audit: actual qty picked up)
// - Always sets `collection_photo` if a file was supplied.
// - Quantity-mismatch behaviour:
// * Full (received === expected) → no extra flags. Clean collection.
// * Partial (0 < received < expected) → `problem_flag = true`,
// `problem_type = "stock"`, and a stock-shortage note in
// `problem_notes`. The operator still sees the item as
// collected but the problem flag surfaces it in the Problems
// tab so a follow-up can be planned.
// * Over (received > expected) → `problem_flag = true` and a
// `problem_notes` entry explaining the overage. No
// `problem_type` because it's not a stock issue — the supplier
// gave more than ordered, which can mean a packing error or a
// promo. We leave the type empty so the operator picks.
//
// The helper intentionally doesn't compute `expected` itself — the
// caller passes `expectedQty` so the source of truth for "what was
// ordered" stays at the call site (where the same number was just
// shown to the driver in the modal).
export const updateCollectionStatus = async (
id: string,
status: string,
data: {
qty_collected?: number;
notes?: string;
collected_date?: string;
photo?: File | null;
expectedQty?: number;
problem_type?: string;
problem_flag?: boolean;
}
) => {
const patch: Record<string, unknown> = {
item_status: status,
drv_status: 'collected',
drv_collected_at: new Date().toISOString(),
qty_collected: data.qty_collected,
collected_date: data.collected_date ?? new Date().toISOString(),
};
// Only forward the optional photo if a file was actually attached.
// Passing `null`/undefined to a PB file field would clear an existing
// attachment on subsequent updates, which we don't want for a
// re-collect after the first one.
if (data.photo) patch.collection_photo = data.photo;
// Notes field keeps the driver's free-text commentary.
if (data.notes !== undefined) patch.notes = data.notes;
// Quantity-mismatch handling — see the doc-comment above.
const received = typeof data.qty_collected === 'number' ? data.qty_collected : 0;
const expected = typeof data.expectedQty === 'number' ? data.expectedQty : 0;
if (expected > 0 && received > 0 && received < expected) {
// Partial — short pickup. Flag so it surfaces in Problems.
patch.problem_flag = true;
patch.problem_type = 'stock';
const userNote = (data.notes ?? '').trim();
const autoNote = `Partial collection: ${received} of ${expected} units`;
patch.problem_notes = userNote ? `${autoNote} — ${userNote}` : autoNote;
} else if (expected > 0 && received > expected) {
// Over-collection — supplier gave more than ordered. Flag without
// a stock type so the operator picks the right follow-up.
patch.problem_flag = true;
patch.problem_type = '';
const userNote = (data.notes ?? '').trim();
const autoNote = `Over collection: ${received} units received against ${expected} ordered`;
patch.problem_notes = userNote ? `${autoNote} — ${userNote}` : autoNote;
}
return await pb.collection('order_items').update(id, patch);
};
// ---------- Bulk helpers (no modal, used for "mark all collected" on
// a PO sub-group) ----------
//
// These skip the per-item modal flow (no photo, no per-item notes
// prompt) and stamp each row straight to its terminal status. The
// driver is presumed to have visually verified the items before
// tapping "Mark all collected on PO #1234" — the PO sub-header lists
// every item under it.
//
// Returns the count of items that were successfully transitioned.
// Items that throw are logged and excluded from the count so a single
// bad row doesn't poison the whole batch — the caller can decide
// whether to surface a follow-up toast for the failures.
export const bulkCollect = async (items: OrderItem[]): Promise<{ ok: number; failed: number }> => {
let ok = 0;
let failed = 0;
for (const item of items) {
try {
const expectedQty = getItemDisplayQty(item);
await pb.collection('order_items').update(item.id, {
item_status: 'collected',
drv_status: 'collected',
drv_collected_at: new Date().toISOString(),
qty_collected: expectedQty,
collected_date: new Date().toISOString(),
});
ok += 1;
} catch (e) {
failed += 1;
console.warn('[bulkCollect] failed for', item.id, e);
}
}
return { ok, failed };
};
// Flag every row as a generic problem — used by the "Flag all" action
// on a PO sub-group. Each row gets the supplied note so the operator
// can see what was reported in bulk (no per-item modal flow).
export const bulkProblem = async (
items: OrderItem[],
description: string
): Promise<{ ok: number; failed: number }> => {
let ok = 0;
let failed = 0;
for (const item of items) {
try {
await pb.collection('order_items').update(item.id, {
item_status: 'problem',
problem_type: 'other',
problem_flag: true,
problem_notes: description,
});
ok += 1;
} catch (e) {
failed += 1;
console.warn('[bulkProblem] failed for', item.id, e);
}
}
return { ok, failed };
};
// ---------- Attachments ----------
// Upload a pickup photo by appending it to the order_item's
// `order_item_attachements` file field (PB file field, max multi).
// Uses the `fieldName+` append syntax so each upload is preserved
// alongside any previous files rather than replacing them.
//
// The source/notes params are accepted for interface compatibility
// but cannot be stored per-file without a separate attachment collection.
// Both are safe to drop here since the driver always uses
// source=driver_proof and notes go into order_items.notes separately.
//
// Throws on PB errors so the caller can roll back the status change.
export const uploadOrderItemAttachment = async (params: {
orderItemId: string;
file: File;
source?: AttachmentSource;
notes?: string;
}): Promise<OrderItemAttachment> => {
const { orderItemId, file } = params;
const formData = new FormData();
// fieldName+ = append (never overwrite) existing files on the row
formData.append('order_item_attachements+', file);
const updated = await pb.collection('order_items').update(orderItemId, formData);
// PB returns the updated order_item; extract the last filename as the
// "id" for the returned OrderItemAttachment shape.
const filenames: string[] = (updated as unknown as { order_item_attachements?: string[] }).order_item_attachements ?? [];
const filename = filenames[filenames.length - 1] ?? file.name;
return {
id: filename, // no separate attachment id; filename is the key
order_item: orderItemId,
filename,
file: filename,
source: 'driver_proof',
uploaded_at: new Date().toISOString(),
} as unknown as OrderItemAttachment;
};
// List attachment filenames for an order item by fetching the file list
// from the order_item_attachements file field on the order_items row.
export const listOrderItemAttachments = async (
orderItemId: string
): Promise<OrderItemAttachment[]> => {
const item = await pb.collection('order_items').getOne(orderItemId);
const filenames: string[] = (item as unknown as { order_item_attachements?: string[] }).order_item_attachements ?? [];
return filenames.map((filename) => ({
id: filename,
order_item: orderItemId,
filename,
file: filename,
source: 'driver_proof' as const,
uploaded_at: '',
})) as unknown as OrderItemAttachment[];
};
// ---------- Display helpers ----------
// Per the project brief:
// po_description is the supplier-facing line (preferred on the PO
// pickup screen); description is the customer-facing line. We always
// show the supplier-facing line when it's there, and fall back to the
// customer-facing one for legacy rows.
export const getItemDisplayDescription = (item: OrderItem): string => {
const po = (item.po_description || '').trim();
if (po) return po;
return (item.description || '').trim() || '—';
};
// order_qty is the supplier-facing quantity (preferred on the pickup
// screen); quantity is the customer-facing quantity. Per the brief:
// order_qty "falls back to quantity if blank". We honor that order.
export const getItemDisplayQty = (item: OrderItem): number => {
const oq = item.order_qty;
if (typeof oq === 'number' && !Number.isNaN(oq) && oq > 0) return oq;
const q = item.quantity;
if (typeof q === 'number' && !Number.isNaN(q) && q > 0) return q;
return 0;
};
// ---------- Driver-side claim action ----------
// When the driver sees an open item in Pool and wants to take it:
// set drv_id = me, item_status = "assigned", assigned_at = now.
export const claimItemForDriver = async (itemId: string) => {
const user = getCurrentUser();
if (!user) throw new Error('Not signed in');
return await pb.collection('order_items').update(itemId, {
drv_id: user.id,
item_status: 'assigned',
drv_status: 'assigned',
assigned_at: new Date().toISOString(),
});
};
// Report a problem on an order item
export const reportProblem = async (
orderItemId: string,
problemType: string,
description: string
) => {
return await pb.collection('order_items').update(orderItemId, {
item_status: 'problem',
problem_type: problemType,
problem_flag: true,
problem_notes: description,
});
};
// Location tracking - tries to save, fails silently if table doesn't exist.
// IMPORTANT: PB schema (verified live 2026-07-08) stores the driver
// relation under `relation`, not `driver`. Writing to `driver` 400s and
// the row is never created — that's why the map polyline has been empty.
// If we ever migrate PB to rename the field back to `driver` (it should
// be — `relation` is a confusing name), update this + getDriverTrail.
export const saveDriverLocation = async (location: Omit<DriverLocation, 'id'>) => {
try {
return await pb.collection('driver_locations').create(location);
} catch (e) {
// Log now (was silent) — silent catches are how the `driver`-field
// bug stayed hidden for months.
console.warn('[saveDriverLocation] failed:', e instanceof Error ? e.message : e);
}
};
// Get driver's location trail for today (for map polyline).
export const getDriverTrail = async (driverId: string) => {
try {
const today = new Date();
today.setHours(0, 0, 0, 0);
const records = await pb.collection('driver_locations').getFullList({
filter: `relation = "${driverId}" && timestamp >= "${today.toISOString()}"`,
sort: 'timestamp',
});
return records as unknown as DriverLocation[];
} catch (e) {
console.warn('[getDriverTrail] failed:', e instanceof Error ? e.message : e);
return [];
}
};
// Settings helpers
const SETTINGS_KEY = '1driver_settings';
export const getSettings = (): AppSettings => {
if (typeof window === 'undefined') return DEFAULT_SETTINGS;
const saved = localStorage.getItem(SETTINGS_KEY);
if (saved) {
try {
return { ...DEFAULT_SETTINGS, ...JSON.parse(saved) };
} catch {
return DEFAULT_SETTINGS;
}
}
return DEFAULT_SETTINGS;
};
export const saveSettings = (settings: Partial<AppSettings>) => {
if (typeof window === 'undefined') return;
const current = getSettings();
const updated = { ...current, ...settings };
localStorage.setItem(SETTINGS_KEY, JSON.stringify(updated));
return updated;
};