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:
parent
da504c45a0
commit
2fb979b98a
12 changed files with 821 additions and 4 deletions
|
|
@ -61,7 +61,7 @@ export async function POST(req: NextRequest) {
|
|||
});
|
||||
|
||||
// 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`;
|
||||
writeFileSync(htmlPath, letterHtml, "utf8");
|
||||
|
||||
|
|
|
|||
41
app/api/tasks/[id]/cancel/route.ts
Normal file
41
app/api/tasks/[id]/cancel/route.ts
Normal 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 });
|
||||
}
|
||||
51
app/api/tasks/[id]/claim/route.ts
Normal file
51
app/api/tasks/[id]/claim/route.ts
Normal 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 });
|
||||
}
|
||||
52
app/api/tasks/[id]/complete/route.ts
Normal file
52
app/api/tasks/[id]/complete/route.ts
Normal 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 });
|
||||
}
|
||||
73
app/api/tasks/[id]/fail/route.ts
Normal file
73
app/api/tasks/[id]/fail/route.ts
Normal 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 });
|
||||
}
|
||||
33
app/api/tasks/[id]/retry/route.ts
Normal file
33
app/api/tasks/[id]/retry/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
88
app/api/tasks/[id]/route.ts
Normal file
88
app/api/tasks/[id]/route.ts
Normal 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
109
app/api/tasks/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
// Root layout for the Workflow frontend. Two responsibilities:
|
||||
// (1) bind the Inter webfont so the typography tokens render with a
|
||||
// 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 { Inter } from "next/font/google";
|
||||
|
|
@ -10,6 +10,7 @@ import "./globals.css";
|
|||
import Providers from "./providers";
|
||||
import QueryProvider from "@/components/QueryProvider";
|
||||
import RouteChrome from "@/components/RouteChrome";
|
||||
import { ToastProvider } from "@/components/Toast";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
|
|
@ -34,7 +35,9 @@ export default function RootLayout({
|
|||
<body className="bg-bg text-fg">
|
||||
<Providers>
|
||||
<QueryProvider>
|
||||
<RouteChrome>{children}</RouteChrome>
|
||||
<RouteChrome>
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</RouteChrome>
|
||||
</QueryProvider>
|
||||
</Providers>
|
||||
</body>
|
||||
|
|
|
|||
173
components/Toast.tsx
Normal file
173
components/Toast.tsx
Normal 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
194
scripts/init_pb_schema.py
Normal 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
Loading…
Reference in a new issue