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
|
// 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");
|
||||||
|
|
||||||
|
|
|
||||||
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:
|
// 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
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