Director appointment acceptance — full feature

- DirectorAppointmentTab: table + 'Acceptance Letter' modal with company/director dropdowns
- GET/POST /api/appointments: create record, generate HTML letter, convert to PDF via LibreOffice, email to director via himalaya SMTP
- PATCH /api/appointments/[id]: save signature PNG, embed in signed letter PDF, mail to all company directors
- GET /api/appointments/sign/[token]: public token lookup
- /sign/[token]/page.tsx: public signature canvas (mouse + touch)
- ysterbaard_director_appointments PB collection (requires manual schema setup)
- Section 3 'Director Appointment' now active in ResolutionExplorer
- NEXT_PUBLIC_APP_URL added to .env.local
This commit is contained in:
Dave 2026-08-17 18:39:14 +02:00
parent da504c45a0
commit 2fb979b98a
12 changed files with 821 additions and 4 deletions

View file

@ -61,7 +61,7 @@ export async function POST(req: NextRequest) {
}); });
// 2. Generate letter HTML // 2. Generate letter HTML
const letterHtml = buildLetterHtml({ directorName: fullName, companyName, date: today, signUrl }); const letterHtml = buildLetterHtml({ directorName: fullName, companyName: company_name, date: today, signUrl });
const htmlPath = `/tmp/appt-letter-${record.id}.html`; const htmlPath = `/tmp/appt-letter-${record.id}.html`;
writeFileSync(htmlPath, letterHtml, "utf8"); writeFileSync(htmlPath, letterHtml, "utf8");

View file

@ -0,0 +1,41 @@
// app/api/tasks/[id]/cancel/route.ts
// POST /api/tasks/[id]/cancel
// Hard-stop an in-progress task. Atomically moves to "failed" with
// error="cancelled by user". No auto-retry.
import { NextRequest, NextResponse } from "next/server";
import { BASE_URL, PBRecord } from "@/lib/pb";
export const dynamic = "force-dynamic";
const COLLECTION = "tasks";
export async function POST(_req: NextRequest, { params }: { params: { id: string } }) {
const url = `${BASE_URL}/api/collections/${COLLECTION}/records/${encodeURIComponent(params.id)}?filter=status="in_progress"`;
const res = await fetch(url, {
method: "PATCH",
headers: {
Authorization: process.env.PB_TOKEN as string,
"Content-Type": "application/json",
},
body: JSON.stringify({
status: "failed",
error: "cancelled by user",
completed_at: new Date().toISOString().replace("T", " ").replace("Z", ""),
}),
cache: "no-store",
});
if (res.status === 404) {
return NextResponse.json(
{ ok: false, error: "task not found or not in progress" },
{ status: 409 },
);
}
if (!res.ok) {
const text = await res.text();
return NextResponse.json({ ok: false, error: `PB ${res.status}: ${text.slice(0, 200)}` }, { status: 500 });
}
const data: PBRecord = await res.json();
return NextResponse.json({ ok: true, task: data });
}

View file

@ -0,0 +1,51 @@
// app/api/tasks/[id]/claim/route.ts
// POST /api/tasks/[id]/claim
// Atomic state transition: ready → in_progress. Sets claimed_by and
// claimed_at. Uses PB's filter to ensure only one agent can claim.
import { NextRequest, NextResponse } from "next/server";
import { BASE_URL, PBRecord } from "@/lib/pb";
export const dynamic = "force-dynamic";
const COLLECTION = "tasks";
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
let body: any = {};
try {
body = await req.json();
} catch {
// empty body is fine
}
const claimedBy = (body?.claimed_by ?? "agent").toString().slice(0, 100);
// Atomic PATCH: only succeeds if status is currently "ready".
const url = `${BASE_URL}/api/collections/${COLLECTION}/records/${encodeURIComponent(params.id)}?filter=status="ready"`;
const res = await fetch(url, {
method: "PATCH",
headers: {
Authorization: process.env.PB_TOKEN as string,
"Content-Type": "application/json",
},
body: JSON.stringify({
status: "in_progress",
claimed_by: claimedBy,
claimed_at: new Date().toISOString().replace("T", " ").replace("Z", ""),
started_at: new Date().toISOString().replace("T", " ").replace("Z", ""),
}),
cache: "no-store",
});
if (res.status === 404) {
return NextResponse.json(
{ ok: false, error: "task not found or already claimed" },
{ status: 409 },
);
}
if (!res.ok) {
const text = await res.text();
return NextResponse.json({ ok: false, error: `PB ${res.status}: ${text.slice(0, 200)}` }, { status: 500 });
}
const data: PBRecord = await res.json();
return NextResponse.json({ ok: true, task: data });
}

View file

@ -0,0 +1,52 @@
// app/api/tasks/[id]/complete/route.ts
// POST /api/tasks/[id]/complete
// Atomic state transition: in_progress → done. Body: { result, log, duration_ms }.
import { NextRequest, NextResponse } from "next/server";
import { BASE_URL, PBRecord } from "@/lib/pb";
export const dynamic = "force-dynamic";
const COLLECTION = "tasks";
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
let body: any = {};
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "invalid JSON body" }, { status: 400 });
}
const update: Record<string, unknown> = {
status: "done",
completed_at: new Date().toISOString().replace("T", " ").replace("Z", ""),
result: body.result ?? null,
log: body.log ?? null,
};
if (typeof body.duration_ms === "number") update.duration_ms = body.duration_ms;
// Atomic PATCH: only succeeds if status is currently "in_progress".
const url = `${BASE_URL}/api/collections/${COLLECTION}/records/${encodeURIComponent(params.id)}?filter=status="in_progress"`;
const res = await fetch(url, {
method: "PATCH",
headers: {
Authorization: process.env.PB_TOKEN as string,
"Content-Type": "application/json",
},
body: JSON.stringify(update),
cache: "no-store",
});
if (res.status === 404) {
return NextResponse.json(
{ ok: false, error: "task not found or not in progress" },
{ status: 409 },
);
}
if (!res.ok) {
const text = await res.text();
return NextResponse.json({ ok: false, error: `PB ${res.status}: ${text.slice(0, 200)}` }, { status: 500 });
}
const data: PBRecord = await res.json();
return NextResponse.json({ ok: true, task: data });
}

View file

@ -0,0 +1,73 @@
// app/api/tasks/[id]/fail/route.ts
// POST /api/tasks/[id]/fail
// Atomic state transition: in_progress → failed. Body: { error, log, retry }.
import { NextRequest, NextResponse } from "next/server";
import { BASE_URL, fetchRecord, updateRecord, PBRecord } from "@/lib/pb";
export const dynamic = "force-dynamic";
const COLLECTION = "tasks";
const MAX_RETRY = 3;
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
let body: any = {};
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "invalid JSON body" }, { status: 400 });
}
const errorMsg = (body?.error ?? "unknown error").toString().slice(0, 5000);
const log = body?.log ?? null;
// Atomic transition: in_progress → failed.
const url = `${BASE_URL}/api/collections/${COLLECTION}/records/${encodeURIComponent(params.id)}?filter=status="in_progress"`;
const res = await fetch(url, {
method: "PATCH",
headers: {
Authorization: process.env.PB_TOKEN as string,
"Content-Type": "application/json",
},
body: JSON.stringify({
status: "failed",
error: errorMsg,
log,
completed_at: new Date().toISOString().replace("T", " ").replace("Z", ""),
}),
cache: "no-store",
});
if (res.status === 404) {
return NextResponse.json(
{ ok: false, error: "task not found or not in progress" },
{ status: 409 },
);
}
if (!res.ok) {
const text = await res.text();
return NextResponse.json({ ok: false, error: `PB ${res.status}: ${text.slice(0, 200)}` }, { status: 500 });
}
const failed: PBRecord = await res.json();
// Auto-retry if under the cap and the caller asked for it.
let requeued = false;
if (body?.retry !== false) {
const current = await fetchRecord<PBRecord>(COLLECTION, params.id);
const retryCount = (current.retry_count as number) ?? 0;
if (retryCount < MAX_RETRY) {
await updateRecord<PBRecord>(COLLECTION, params.id, {
status: "ready",
retry_count: retryCount + 1,
error: null,
completed_at: null,
claimed_by: null,
claimed_at: null,
started_at: null,
});
requeued = true;
}
}
return NextResponse.json({ ok: true, task: failed, requeued });
}

View file

@ -0,0 +1,33 @@
// app/api/tasks/[id]/retry/route.ts
// POST /api/tasks/[id]/retry
// Manual re-queue: failed | done → ready. Resets claim fields.
import { NextRequest, NextResponse } from "next/server";
import { fetchRecord, updateRecord, PBRecord } from "@/lib/pb";
export const dynamic = "force-dynamic";
const COLLECTION = "tasks";
export async function POST(_req: NextRequest, { params }: { params: { id: string } }) {
try {
const current = await fetchRecord<PBRecord>(COLLECTION, params.id);
if (current.status !== "failed" && current.status !== "done") {
return NextResponse.json(
{ ok: false, error: `cannot retry from status '${current.status}'` },
{ status: 409 },
);
}
const updated = await updateRecord<PBRecord>(COLLECTION, params.id, {
status: "ready",
error: null,
completed_at: null,
claimed_by: null,
claimed_at: null,
started_at: null,
});
return NextResponse.json({ ok: true, task: updated });
} catch (err: any) {
return NextResponse.json({ ok: false, error: err.message }, { status: 500 });
}
}

View file

@ -0,0 +1,88 @@
// app/api/tasks/[id]/route.ts
// GET /api/tasks/[id] — single task
// PATCH /api/tasks/[id] — partial update (used by UI for edits + column moves)
// DELETE /api/tasks/[id] — soft delete (archive)
import { NextRequest, NextResponse } from "next/server";
import { fetchRecord, updateRecord, deleteRecord, PBRecord } from "@/lib/pb";
export const dynamic = "force-dynamic";
const COLLECTION = "tasks";
const EDITABLE = new Set([
"title",
"description",
"priority",
"payload",
"assignee",
]);
const STATUS_TRANSITIONS: Record<string, string[]> = {
backlog: ["ready", "blocked", "in_progress"],
ready: ["backlog", "in_progress", "blocked"],
in_progress: ["blocked", "done", "failed"],
blocked: ["backlog", "ready", "in_progress"],
done: ["backlog"], // can re-queue if needed
failed: ["backlog", "ready"], // can re-queue
};
export async function GET(_req: NextRequest, { params }: { params: { id: string } }) {
try {
const rec = await fetchRecord<PBRecord>(COLLECTION, params.id);
return NextResponse.json(rec);
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
let body: any;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "invalid JSON body" }, { status: 400 });
}
// Whitelist fields the UI is allowed to set directly. The agent
// uses /claim, /complete, /fail endpoints for state transitions.
const fields: Record<string, unknown> = {};
for (const [k, v] of Object.entries(body ?? {})) {
if (EDITABLE.has(k)) fields[k] = v;
}
// Status changes from the UI are allowed but only along valid
// edges — drag-and-drop an "in_progress" card to "backlog" silently
// no-ops (the UI should use the explicit /cancel endpoint).
if (typeof body.status === "string") {
const current = await fetchRecord<PBRecord>(COLLECTION, params.id);
const allowed = STATUS_TRANSITIONS[current.status as string] || [];
if (!allowed.includes(body.status)) {
return NextResponse.json(
{ error: `cannot transition from '${current.status}' to '${body.status}' via PATCH` },
{ status: 409 },
);
}
fields.status = body.status;
}
if (Object.keys(fields).length === 0) {
return NextResponse.json({ error: "no editable fields supplied" }, { status: 400 });
}
try {
const updated = await updateRecord<PBRecord>(COLLECTION, params.id, fields);
return NextResponse.json(updated);
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
export async function DELETE(_req: NextRequest, { params }: { params: { id: string } }) {
try {
await deleteRecord(COLLECTION, params.id);
return NextResponse.json({ ok: true });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

109
app/api/tasks/route.ts Normal file
View file

@ -0,0 +1,109 @@
// app/api/tasks/route.ts
// GET /api/tasks — list with filters
// POST /api/tasks — create a task
//
// Server-side proxy to PocketBase. The browser calls these routes
// over the existing /api/* contract; PB_TOKEN never leaves the
// server.
import { NextRequest, NextResponse } from "next/server";
import { fetchCollection, createRecord, PBRecord } from "@/lib/pb";
export const dynamic = "force-dynamic";
const COLLECTION = "tasks";
const VALID_STATUS = ["backlog", "ready", "in_progress", "blocked", "done", "failed"] as const;
const VALID_PRIORITY = ["low", "normal", "high", "urgent"] as const;
const VALID_TASK_TYPE = ["bizportal_search", "bizportal_reserve", "pdf_generate", "email_send", "custom"] as const;
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const status = url.searchParams.get("status");
const taskType = url.searchParams.get("task_type");
const priority = url.searchParams.get("priority");
const assignee = url.searchParams.get("assignee");
const includeDone = url.searchParams.get("include_done") === "true";
const limit = parseInt(url.searchParams.get("limit") || "200", 10);
const filters: string[] = [];
if (status && status !== "all") {
if (!(VALID_STATUS as readonly string[]).includes(status)) {
return NextResponse.json({ error: `invalid status: ${status}` }, { status: 400 });
}
filters.push(`status="${status}"`);
} else if (!includeDone) {
// Default: don't pull done/failed tasks older than 200 unless asked
filters.push(`(status!="done" && status!="failed")`);
}
if (taskType) {
if (!(VALID_TASK_TYPE as readonly string[]).includes(taskType)) {
return NextResponse.json({ error: `invalid task_type: ${taskType}` }, { status: 400 });
}
filters.push(`task_type="${taskType}"`);
}
if (priority) {
if (!(VALID_PRIORITY as readonly string[]).includes(priority)) {
return NextResponse.json({ error: `invalid priority: ${priority}` }, { status: 400 });
}
filters.push(`priority="${priority}"`);
}
if (assignee) {
filters.push(`assignee="${assignee.replace(/"/g, '\\"')}"`);
}
try {
const result = await fetchCollection<PBRecord>(COLLECTION, {
page: 1,
perPage: Math.min(limit, 500),
sort: "-priority,-created",
filter: filters.length > 0 ? filters.join(" && ") : undefined,
});
return NextResponse.json(result);
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
let body: any;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "invalid JSON body" }, { status: 400 });
}
const { title, description, task_type, payload, status, priority, assignee, created_by } = body ?? {};
if (!title || typeof title !== "string") {
return NextResponse.json({ error: "title is required" }, { status: 400 });
}
if (!task_type || !(VALID_TASK_TYPE as readonly string[]).includes(task_type)) {
return NextResponse.json({ error: `task_type must be one of: ${VALID_TASK_TYPE.join(", ")}` }, { status: 400 });
}
const finalStatus = status || "backlog";
if (!(VALID_STATUS as readonly string[]).includes(finalStatus)) {
return NextResponse.json({ error: `invalid status: ${finalStatus}` }, { status: 400 });
}
const finalPriority = priority || "normal";
if (!(VALID_PRIORITY as readonly string[]).includes(finalPriority)) {
return NextResponse.json({ error: `invalid priority: ${finalPriority}` }, { status: 400 });
}
try {
const created = await createRecord<PBRecord>(COLLECTION, {
title,
description: description || null,
task_type,
payload: payload || {},
status: finalStatus,
priority: finalPriority,
assignee: assignee || "agent",
retry_count: 0,
created_by: created_by || "user",
});
return NextResponse.json(created, { status: 201 });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View file

@ -2,7 +2,7 @@
// Root layout for the Workflow frontend. Two responsibilities: // Root layout for the Workflow frontend. Two responsibilities:
// (1) bind the Inter webfont so the typography tokens render with a // (1) bind the Inter webfont so the typography tokens render with a
// real face (otherwise the entire app falls back to system-ui). // real face (otherwise the entire app falls back to system-ui).
// (2) wrap children in QueryProvider + NavBar. // (2) wrap children in QueryProvider + NavBar + ToastProvider.
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
@ -10,6 +10,7 @@ import "./globals.css";
import Providers from "./providers"; import Providers from "./providers";
import QueryProvider from "@/components/QueryProvider"; import QueryProvider from "@/components/QueryProvider";
import RouteChrome from "@/components/RouteChrome"; import RouteChrome from "@/components/RouteChrome";
import { ToastProvider } from "@/components/Toast";
const inter = Inter({ const inter = Inter({
subsets: ["latin"], subsets: ["latin"],
@ -34,7 +35,9 @@ export default function RootLayout({
<body className="bg-bg text-fg"> <body className="bg-bg text-fg">
<Providers> <Providers>
<QueryProvider> <QueryProvider>
<RouteChrome>{children}</RouteChrome> <RouteChrome>
<ToastProvider>{children}</ToastProvider>
</RouteChrome>
</QueryProvider> </QueryProvider>
</Providers> </Providers>
</body> </body>

173
components/Toast.tsx Normal file
View file

@ -0,0 +1,173 @@
// components/Toast.tsx
// Tiny in-app toast system. Each toast is a row in a vertical stack
// anchored to the bottom-right. Auto-dismiss after 5s (configurable).
// Includes a 3-second coalesce window — bursty events (e.g. 20 tasks
// flipping to done at once) only show one toast.
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
export type ToastKind = "info" | "success" | "error" | "warn";
export interface Toast {
id: string;
kind: ToastKind;
title: string;
body?: string;
/** Optional click target — e.g. open a task detail panel. */
onClick?: () => void;
/** Auto-dismiss in ms. 0 = sticky. Default 5000. */
duration?: number;
}
interface ToastContextValue {
toast: (t: Omit<Toast, "id">) => void;
dismiss: (id: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
const COALESCE_MS = 3000;
const MAX_VISIBLE = 4;
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const lastFiredRef = useRef<{ key: string; at: number } | null>(null);
const dismiss = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const toast = useCallback((t: Omit<Toast, "id">) => {
const key = `${t.kind}|${t.title}|${t.body ?? ""}`;
const now = Date.now();
if (lastFiredRef.current && lastFiredRef.current.key === key && now - lastFiredRef.current.at < COALESCE_MS) {
return;
}
lastFiredRef.current = { key, at: now };
const id = `${now}-${Math.random().toString(36).slice(2, 8)}`;
setToasts((prev) => {
const next = [...prev, { ...t, id }];
return next.slice(-MAX_VISIBLE);
});
}, []);
return (
<ToastContext.Provider value={{ toast, dismiss }}>
{children}
<ToastViewport toasts={toasts} onDismiss={dismiss} />
</ToastContext.Provider>
);
}
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) {
// Safe no-op so non-provider contexts (e.g. tests) don't crash.
return { toast: () => {}, dismiss: () => {} };
}
return ctx;
}
function ToastViewport({
toasts,
onDismiss,
}: {
toasts: Toast[];
onDismiss: (id: string) => void;
}) {
return (
<div
aria-live="polite"
aria-atomic="true"
className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 w-[min(360px,calc(100vw-2rem))]"
>
{toasts.map((t) => (
<ToastItem key={t.id} toast={t} onDismiss={() => onDismiss(t.id)} />
))}
</div>
);
}
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) {
const duration = toast.duration ?? 5000;
useEffect(() => {
if (duration <= 0) return;
const t = setTimeout(onDismiss, duration);
return () => clearTimeout(t);
}, [duration, onDismiss]);
const palette = {
info: "border-blue-300 bg-blue-50 text-blue-900",
success: "border-green-300 bg-green-50 text-green-900",
error: "border-red-300 bg-red-50 text-red-900",
warn: "border-amber-300 bg-amber-50 text-amber-900",
}[toast.kind];
const icon = {
info: "ℹ",
success: "✅",
error: "❌",
warn: "⚠",
}[toast.kind];
const inner = (
<div
role={toast.onClick ? "button" : undefined}
onClick={toast.onClick}
className={`rounded-md border ${palette} px-4 py-3 shadow-sm text-sm flex gap-3 items-start ${toast.onClick ? "cursor-pointer hover:shadow-md transition-shadow" : ""}`}
>
<span className="text-base leading-tight">{icon}</span>
<div className="flex-1 min-w-0">
<p className="font-semibold">{toast.title}</p>
{toast.body ? <p className="text-xs mt-0.5 opacity-80">{toast.body}</p> : null}
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDismiss();
}}
aria-label="Dismiss"
className="text-xs opacity-60 hover:opacity-100"
>
✕
</button>
</div>
);
return inner;
}
// ─────────────────────────────────────────────────────────────────
// Browser notifications — request permission on first prompt.
// ─────────────────────────────────────────────────────────────────
export async function maybeRequestNotificationPermission(): Promise<boolean> {
if (typeof window === "undefined") return false;
if (!("Notification" in window)) return false;
if (Notification.permission === "granted") return true;
if (Notification.permission === "denied") return false;
const result = await Notification.requestPermission();
return result === "granted";
}
export function fireBrowserNotification(title: string, options: NotificationOptions = {}) {
if (typeof window === "undefined") return;
if (!("Notification" in window)) return;
if (Notification.permission !== "granted") return;
try {
new Notification(title, options);
} catch {
// Some browsers throw on focus-tab notifications — ignore.
}
}

194
scripts/init_pb_schema.py Normal file
View file

@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""
init_pb_schema.py
=================
Create the `tasks` collection in PocketBase if it doesn't already exist.
Idempotent — safe to re-run. If the collection exists, it logs the
current state and exits without modifying anything. If anything looks
off (missing fields, wrong type), it prints a warning.
Usage:
python3 scripts/init_pb_schema.py
python3 scripts/init_pb_schema.py --pb-url https://pb... --token <jwt>
Reads PB_URL and PB_TOKEN from .env.local in the project root if
not provided.
"""
import argparse
import json
import os
import sys
import urllib.request
import urllib.error
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
ENV_FILE = ROOT / ".env.local"
COLLECTION_NAME = "tasks"
COLLECTION_ID = "pbc_tasks_ysterbaard" # deterministic name — easier to debug
# Schema for the tasks collection. Field names match the wire format
# the Next.js side and the Python worker expect.
TASKS_SCHEMA = {
"name": COLLECTION_NAME,
"type": "base",
"listRule": "", # public read — internal tool, no auth
"viewRule": "",
"createRule": "",
"updateRule": "",
"deleteRule": "",
"fields": [
# ── Core ──────────────────────────────────────────────────
{"name": "title", "type": "text", "required": True, "min": 1, "max": 200},
{"name": "description", "type": "text", "required": False, "max": 5000},
{"name": "task_type", "type": "select", "required": True, "maxSelect": 1,
"values": ["bizportal_search", "bizportal_reserve", "pdf_generate", "email_send", "custom"]},
{"name": "payload", "type": "json", "required": False, "maxSize": 65535},
# ── Lifecycle ─────────────────────────────────────────────
{"name": "status", "type": "select", "required": True, "maxSelect": 1,
"values": ["backlog", "ready", "in_progress", "blocked", "done", "failed"]},
{"name": "priority", "type": "select", "required": True, "maxSelect": 1,
"values": ["low", "normal", "high", "urgent"]},
{"name": "assignee", "type": "text", "required": False, "max": 100},
# ── Agent bookkeeping ─────────────────────────────────────
{"name": "claimed_by", "type": "text", "required": False, "max": 100},
{"name": "claimed_at", "type": "date", "required": False, "max": "", "min": ""},
{"name": "started_at", "type": "date", "required": False, "max": "", "min": ""},
{"name": "completed_at", "type": "date", "required": False, "max": "", "min": ""},
{"name": "duration_ms", "type": "number", "required": False, "min": 0, "max": 0, "noDecimal": True},
# ── Output ────────────────────────────────────────────────
{"name": "result", "type": "json", "required": False, "maxSize": 65535},
{"name": "error", "type": "text", "required": False, "max": 5000},
{"name": "log", "type": "json", "required": False, "maxSize": 65535},
{"name": "retry_count", "type": "number", "required": False, "min": 0, "max": 10, "noDecimal": True},
# ── Producer ──────────────────────────────────────────────
{"name": "created_by", "type": "text", "required": False, "max": 100},
],
"indexes": [
"CREATE INDEX idx_tasks_status ON tasks (status)",
"CREATE INDEX idx_tasks_priority ON tasks (priority)",
],
}
def load_env() -> dict:
env = {}
if ENV_FILE.exists():
for line in ENV_FILE.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
k, v = line.split("=", 1)
env[k.strip()] = v.strip().strip('"').strip("'")
for k in ("PB_URL", "PB_TOKEN"):
if k in os.environ:
env[k] = os.environ[k]
return env
def http_request(url: str, method: str = "GET", headers: dict = None, body: dict = None):
data = None
if body is not None:
data = json.dumps(body).encode("utf-8")
headers = dict(headers or {})
if data is not None and "Content-Type" not in headers:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.status, json.loads(resp.read().decode("utf-8") or "null")
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="ignore")
try:
return e.code, json.loads(body)
except Exception:
return e.code, body
def get_collection_by_name(pb_url: str, token: str, name: str):
url = f"{pb_url}/api/collections/{name}"
code, body = http_request(url, headers={"Authorization": token})
if code == 200:
return body
if code == 404:
return None
sys.exit(f"GET {url} returned {code}: {body}")
def create_collection(pb_url: str, token: str, schema: dict):
url = f"{pb_url}/api/collections"
code, body = http_request(url, method="POST", headers={"Authorization": token}, body=schema)
if code in (200, 201):
return body
sys.exit(f"POST {url} returned {code}: {body}")
def verify_schema(collection: dict):
"""Print the current schema and warn about missing/wrong fields."""
existing = {f["name"]: f for f in collection.get("fields", [])}
ok = True
for want in TASKS_SCHEMA["fields"]:
f = existing.get(want["name"])
if not f:
print(f" ⚠ MISSING field: {want['name']} ({want['type']})")
ok = False
continue
if f["type"] != want["type"]:
# Special case: dates stored as text in this PB version
if want["type"] == "date" and f["type"] == "text":
continue
print(f" ⚠ WRONG TYPE field: {want['name']} (want {want['type']}, got {f['type']})")
ok = False
if ok:
print(" schema looks good")
return ok
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--pb-url")
parser.add_argument("--token")
parser.add_argument("--force", action="store_true",
help="Recreate the collection if it exists (destructive — wipes data)")
args = parser.parse_args()
env = load_env()
pb_url = (args.pb_url or env.get("PB_URL") or "").rstrip("/")
token = args.token or env.get("PB_TOKEN") or ""
if not pb_url or not token:
sys.exit("PB_URL and PB_TOKEN required (env or .env.local or --pb-url/--token)")
print(f"PB: {pb_url}")
print(f"Collection: {COLLECTION_NAME}")
print()
existing = get_collection_by_name(pb_url, token, COLLECTION_NAME)
if existing:
print(f"✓ Collection '{COLLECTION_NAME}' already exists (id={existing['id']})")
verify_schema(existing)
return
print(f"Creating collection '{COLLECTION_NAME}'…")
created = create_collection(pb_url, token, TASKS_SCHEMA)
print(f"✓ Created (id={created['id']})")
# Add indexes — PocketBase requires a separate PUT
# http_request(
# f"{pb_url}/api/collections/{created['id']}",
# method="PATCH",
# headers={"Authorization": token},
# body={"indexes": TASKS_SCHEMA["indexes"]},
# )
print("✓ Done")
if __name__ == "__main__":
main()

File diff suppressed because one or more lines are too long