diff --git a/.env.example b/.env.example index f809c5d..84208e9 100644 --- a/.env.example +++ b/.env.example @@ -50,3 +50,20 @@ CLICKSTACK_OTLP_URL= # secret behind the /gate page. Leave both unset to run without a gate. ACCESS_CODES= ACCESS_TOKEN_SECRET= + +# DoorDash ordering (optional). Requires the official dd-cli on the worker +# and a personal account; docs/DEPLOYMENT.md covers the whole ritual. +# Approval codes are emailed here and nowhere else; whoever owns this inbox +# owns every order. +RENDI_OWNER_EMAIL= +# Local dev signs in with `dd-cli login` (keychain). Deployed workers are +# headless and need an exported token instead: `dd-cli export-token`. +DD_CLI_ACCESS_TOKEN= +# Spend caps, enforced below the agent. Defaults: $100 per order, 3 orders +# per day, 15 approval requests per day. +DD_MAX_ORDER_CENTS= +DD_MAX_ORDERS_PER_DAY= +DD_MAX_APPROVALS_PER_DAY= +# Set to 1 when strangers hold gate codes: hides saved addresses, payment +# methods, and order history from the agent entirely. +DD_HIDE_PERSONAL= diff --git a/.env.trigger.example b/.env.trigger.example index 9613383..b35e0e8 100644 --- a/.env.trigger.example +++ b/.env.trigger.example @@ -22,3 +22,14 @@ RENDI_MODEL=claude-opus-4-8 RENDI_APP_URL= GITHUB_TOKEN= + +# DoorDash ordering (optional; leave unset to run without it). Deployed +# workers are headless: mint the token with `dd-cli export-token` on a +# machine where you are signed in. Approval codes are emailed to the owner +# address and nowhere else. +DD_CLI_ACCESS_TOKEN= +RENDI_OWNER_EMAIL= +DD_MAX_ORDER_CENTS= +DD_MAX_ORDERS_PER_DAY= +DD_MAX_APPROVALS_PER_DAY= +DD_HIDE_PERSONAL= diff --git a/app/(app)/c/[id]/page.tsx b/app/(app)/c/[id]/page.tsx index e95482c..4e47a95 100644 --- a/app/(app)/c/[id]/page.tsx +++ b/app/(app)/c/[id]/page.tsx @@ -20,6 +20,7 @@ export default async function ConversationPage({ const [transcript, canvas] = conversation ? await Promise.all([getTranscript(id), loadCanvas(id)]) : [[], undefined]; + const archived = conversation ? isArchived(conversation.triggerEnv) : false; return ( <>

{conversation?.title ?? "New conversation"}

@@ -31,9 +32,11 @@ export default async function ConversationPage({ chatId={id} initialMessages={transcript} initialCanvas={canvas ?? null} - archived={conversation ? isArchived(conversation.createdAt) : false} + archived={archived} session={ - conversation?.publicAccessToken + // A gallery never attaches to its session: the other + // environment's token cannot resume here, only 401. + conversation?.publicAccessToken && !archived ? { publicAccessToken: conversation.publicAccessToken, lastEventId: conversation.lastEventId ?? undefined, diff --git a/app/api/doordash/approvals/[id]/cancel/route.ts b/app/api/doordash/approvals/[id]/cancel/route.ts new file mode 100644 index 0000000..456028f --- /dev/null +++ b/app/api/doordash/approvals/[id]/cancel/route.ts @@ -0,0 +1,34 @@ +import { eq } from "drizzle-orm"; +import { getDb } from "@/lib/db/index"; +import { ddApprovals } from "@/lib/db/schema"; +import { voidApproval } from "@/lib/rendi/doordash-approval"; +import { setCartStatus } from "@/lib/rendi/doordash-db"; + +// The card's cancel button: voids an unconsumed approval and reopens +// the cart for editing. A consumed approval already became an order; +// cancelling here must never rewind that cart. +export async function POST( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const approvalId = Number(id); + if (!Number.isInteger(approvalId)) { + return Response.json({ error: "bad approval" }, { status: 400 }); + } + const [row] = await getDb() + .select({ + cartUuid: ddApprovals.cartUuid, + consumedAt: ddApprovals.consumedAt, + }) + .from(ddApprovals) + .where(eq(ddApprovals.id, approvalId)); + if (!row) + return Response.json({ error: "no such approval" }, { status: 404 }); + if (row.consumedAt) { + return Response.json({ error: "already used" }, { status: 409 }); + } + await voidApproval(approvalId); + await setCartStatus(row.cartUuid, "open"); + return Response.json({ ok: true }); +} diff --git a/app/api/doordash/approvals/[id]/route.ts b/app/api/doordash/approvals/[id]/route.ts new file mode 100644 index 0000000..75cbb80 --- /dev/null +++ b/app/api/doordash/approvals/[id]/route.ts @@ -0,0 +1,44 @@ +import { eq } from "drizzle-orm"; +import { getDb } from "@/lib/db/index"; +import { ddApprovals } from "@/lib/db/schema"; + +export type ApprovalStatus = + | "waiting" + | "verified" + | "consumed" + | "voided" + | "expired"; + +// The card re-renders from the transcript on every reload, so it asks +// the row where things actually stand before offering a code input. +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const approvalId = Number(id); + if (!Number.isInteger(approvalId)) { + return Response.json({ error: "bad approval" }, { status: 400 }); + } + const [row] = await getDb() + .select({ + consumedAt: ddApprovals.consumedAt, + voidedAt: ddApprovals.voidedAt, + verifiedAt: ddApprovals.verifiedAt, + expiresAt: ddApprovals.expiresAt, + }) + .from(ddApprovals) + .where(eq(ddApprovals.id, approvalId)); + if (!row) + return Response.json({ error: "no such approval" }, { status: 404 }); + const status: ApprovalStatus = row.consumedAt + ? "consumed" + : row.voidedAt + ? "voided" + : row.verifiedAt + ? "verified" + : row.expiresAt.getTime() < Date.now() + ? "expired" + : "waiting"; + return Response.json({ status, expiresAt: row.expiresAt.toISOString() }); +} diff --git a/app/api/doordash/approvals/[id]/verify/route.ts b/app/api/doordash/approvals/[id]/verify/route.ts new file mode 100644 index 0000000..e3bcec4 --- /dev/null +++ b/app/api/doordash/approvals/[id]/verify/route.ts @@ -0,0 +1,42 @@ +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import { getDb } from "@/lib/db/index"; +import { ddApprovals } from "@/lib/db/schema"; +import { verifyApproval } from "@/lib/rendi/doordash-approval"; +import { sendSessionText } from "@/lib/rendi/nudge"; + +// The code's only door. Deterministic verification, no model anywhere +// in the path; on success the session inbox wakes the agent, the +// three-writers pattern doing what it was born for. + +const bodySchema = z.object({ code: z.string().min(1).max(12) }); + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const approvalId = Number(id); + if (!Number.isInteger(approvalId)) { + return Response.json({ error: "bad approval" }, { status: 400 }); + } + const parsed = bodySchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) { + return Response.json({ error: "bad code" }, { status: 400 }); + } + const result = await verifyApproval(approvalId, parsed.data.code); + if (!result.ok) { + return Response.json(result, { status: 400 }); + } + const [row] = await getDb() + .select({ conversationId: ddApprovals.conversationId }) + .from(ddApprovals) + .where(eq(ddApprovals.id, approvalId)); + if (row) { + await sendSessionText( + row.conversationId, + `[order approved, approval ${approvalId}] The owner entered the code. Place the order with doordash-submit.`, + ); + } + return Response.json({ ok: true }); +} diff --git a/app/api/doordash/carts/[uuid]/ops/route.ts b/app/api/doordash/carts/[uuid]/ops/route.ts new file mode 100644 index 0000000..12652f3 --- /dev/null +++ b/app/api/doordash/carts/[uuid]/ops/route.ts @@ -0,0 +1,72 @@ +import { runs, tasks } from "@trigger.dev/sdk"; +import { z } from "zod"; +import { getCartSnapshot } from "@/lib/rendi/doordash-db"; +import type { ddCartExec } from "@/trigger/dd-cart-exec"; + +// The card's touch edits. The dd-cli binary lives in the worker, so the +// route triggers the exec task and waits the run out; the fresh snapshot +// comes back for the card to adopt. Actor is the user by construction: +// this route only ever runs from the browser's hand. + +const opSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("set-quantity"), + lineId: z.string(), + quantity: z.number().int().min(0).max(50), + }), + z.object({ kind: z.literal("remove-line"), lineId: z.string() }), + z.object({ + kind: z.literal("set-tip"), + tipCents: z.number().int().min(0).max(50_000), + }), + z.object({ + kind: z.literal("set-fulfillment"), + fulfillment: z.enum(["delivery", "pickup"]), + }), + z.object({ kind: z.literal("preview") }), +]); + +const POLL_MS = 800; +const DEADLINE_MS = 60_000; + +export async function POST( + request: Request, + { params }: { params: Promise<{ uuid: string }> }, +) { + const { uuid } = await params; + const snapshot = await getCartSnapshot(uuid); + if (!snapshot) { + return Response.json({ error: "unknown cart" }, { status: 404 }); + } + const parsed = opSchema.safeParse(await request.json()); + if (!parsed.success) { + return Response.json({ error: "malformed op" }, { status: 400 }); + } + const handle = await tasks.trigger("rendi-dd-cart-exec", { + cartUuid: uuid, + conversationId: snapshot.conversationId, + op: parsed.data, + }); + const startedAt = Date.now(); + while (Date.now() - startedAt < DEADLINE_MS) { + const run = await runs.retrieve(handle.id); + if (run.status === "COMPLETED") { + const fresh = await getCartSnapshot(uuid); + return Response.json({ ok: true, cart: fresh ?? null }); + } + if ( + run.status === "FAILED" || + run.status === "CRASHED" || + run.status === "CANCELED" || + run.status === "SYSTEM_FAILURE" || + run.status === "TIMED_OUT" + ) { + return Response.json( + { error: "the edit did not go through" }, + { status: 502 }, + ); + } + await new Promise((resolve) => setTimeout(resolve, POLL_MS)); + } + return Response.json({ error: "edit timed out" }, { status: 504 }); +} diff --git a/components/chat/archive-notice.stories.tsx b/components/chat/archive-notice.stories.tsx index e3a8df9..7703b03 100644 --- a/components/chat/archive-notice.stories.tsx +++ b/components/chat/archive-notice.stories.tsx @@ -14,9 +14,7 @@ type Story = StoryObj; export const Default: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await expect( - canvas.getByText(/archived conversation from Rendi/), - ).toBeVisible(); + await expect(canvas.getByText(/kept as a live gallery/)).toBeVisible(); await expect( canvas.getByRole("link", { name: "New conversation" }), ).toHaveAttribute("href", "/"); diff --git a/components/chat/archive-notice.tsx b/components/chat/archive-notice.tsx index 0f97672..ce78a32 100644 --- a/components/chat/archive-notice.tsx +++ b/components/chat/archive-notice.tsx @@ -16,11 +16,10 @@ export function ArchiveNotice() { />

- An archived conversation from Rendi’s build days, kept as a - gallery. + This conversation is kept as a live gallery.

- Everything renders live. To talk with Rendi, start a new + Everything still renders. To talk with Rendi, start a new conversation.

diff --git a/components/chat/transcript.tsx b/components/chat/transcript.tsx index c40f831..f083722 100644 --- a/components/chat/transcript.tsx +++ b/components/chat/transcript.tsx @@ -23,6 +23,10 @@ import { PulseCard } from "@/components/chat/pulse-card"; import { QueryDataCard } from "@/components/chat/query-data-card"; import { ScreenshotCard } from "@/components/chat/screenshot-card"; import { ShareLinkCard } from "@/components/chat/share-link-card"; +import { ApprovalCard } from "@/components/doordash/approval-card"; +import { CartToolCard } from "@/components/doordash/cart-tool-card"; +import { DoorDashBrowseCard } from "@/components/doordash/doordash-browse-card"; +import { OrderCard } from "@/components/doordash/order-card"; import { persistedInstrumentSpec } from "@/lib/rendi/instrument"; export function Transcript({ @@ -224,6 +228,89 @@ function Parts({ /> ); } + if (part.type === "tool-doordash-cart") { + return ( + [0]["output"]) + : undefined + } + errorText={ + part.state === "output-error" ? part.errorText : undefined + } + /> + ); + } + if (part.type === "tool-doordash-request-approval") { + return ( + [0]["output"]) + : undefined + } + errorText={ + part.state === "output-error" ? part.errorText : undefined + } + /> + ); + } + if (part.type === "tool-doordash-submit") { + return ( + [0]["output"]) + : undefined + } + errorText={ + part.state === "output-error" ? part.errorText : undefined + } + /> + ); + } + if (part.type === "tool-doordash-browse") { + return ( + [0]["input"]) + : undefined + } + output={ + part.state === "output-available" + ? (part.output as Parameters< + typeof DoorDashBrowseCard + >[0]["output"]) + : undefined + } + errorText={ + part.state === "output-error" ? part.errorText : undefined + } + /> + ); + } if (part.type === "tool-sync-commits") { return ( ({ ok: true })), + cancel: fn(async (_id: number) => {}), + fetchStatus: fn(async (_id: number) => "waiting"), + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const CodeEntry: Story = { + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const input = canvas.getByRole("textbox", { name: "Approval code" }); + const approve = canvas.getByRole("button", { name: "Approve" }); + await expect(approve).toBeDisabled(); + await userEvent.type(input, "482913"); + await userEvent.click(approve); + await waitFor(() => expect(args.verify).toHaveBeenCalledWith(41, "482913")); + await expect( + canvas.getByText("approved; rendi is placing the order"), + ).toBeVisible(); + }, +}; + +export const WrongCode: Story = { + args: { + verify: fn(async (_id: number, _code: string) => ({ + ok: false, + reason: "wrong", + attemptsLeft: 4, + })), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = canvas.getByRole("textbox", { name: "Approval code" }); + await userEvent.type(input, "000000"); + await userEvent.click(canvas.getByRole("button", { name: "Approve" })); + await expect( + await canvas.findByText("wrong code, 4 tries left"), + ).toBeVisible(); + // A spent guess never lingers in the field. + await expect(input).toHaveValue(""); + }, +}; + +export const Cancelled: Story = { + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(args.cancel).toHaveBeenCalledWith(41)); + await expect( + canvas.getByText("approval cancelled; the cart is open again"), + ).toBeVisible(); + }, +}; + +export const AlreadyApproved: Story = { + args: { fetchStatus: fn(async (_id: number) => "consumed") }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // A reload after the code was entered never re-offers the input. + await expect( + await canvas.findByText("approved; rendi is placing the order"), + ).toBeVisible(); + await expect( + canvas.queryByRole("textbox", { name: "Approval code" }), + ).not.toBeInTheDocument(); + }, +}; + +export const Denied: Story = { + args: { + output: { + denied: "the order cap is $100 and this cart totals $184.90", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByText("the order cap is $100 and this cart totals $184.90"), + ).toBeVisible(); + }, +}; diff --git a/components/doordash/approval-card.tsx b/components/doordash/approval-card.tsx new file mode 100644 index 0000000..01e7a80 --- /dev/null +++ b/components/doordash/approval-card.tsx @@ -0,0 +1,256 @@ +"use client"; + +import type { ToolUIPart } from "ai"; +import { KeyRound } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Shimmer } from "@/components/ai-elements/shimmer"; +import { Tool, ToolContent, ToolHeader } from "@/components/ai-elements/tool"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +type ApprovalOutput = { + approvalId?: number; + expiresAt?: string; + totalCents?: number; + storeName?: string; + awaiting?: string; + denied?: string; +}; + +type VerifyResponse = { + ok?: boolean; + reason?: string; + attemptsLeft?: number; +}; + +async function postCode( + approvalId: number, + code: string, +): Promise { + const response = await fetch(`/api/doordash/approvals/${approvalId}/verify`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ code }), + }); + return response.json(); +} + +async function postCancel(approvalId: number): Promise { + await fetch(`/api/doordash/approvals/${approvalId}/cancel`, { + method: "POST", + }); +} + +async function getStatus(approvalId: number): Promise { + const response = await fetch(`/api/doordash/approvals/${approvalId}`); + if (!response.ok) return "voided"; + const body = (await response.json()) as { status?: string }; + return body.status ?? "waiting"; +} + +type Phase = + | "waiting" + | "checking" + | "approved" + | "cancelled" + | "voided" + | "expired"; + +const DEAD_NOTES: Record = { + expired: "the code expired; ask rendi for a fresh approval", + locked: "too many tries; this approval is dead", + gone: "this approval is no longer live", +}; + +// The two-key ritual's door. The code from the owner's inbox is typed +// here and POSTs straight to the verify route; it never touches the +// model and never enters the transcript. The transcript re-renders this +// card on every reload, so it asks the row where things stand before +// offering the input again. +export function ApprovalCard({ + state, + output, + errorText, + interrupted = false, + verify = postCode, + cancel = postCancel, + fetchStatus = getStatus, +}: { + state: ToolUIPart["state"]; + output?: ApprovalOutput; + errorText?: string; + interrupted?: boolean; + verify?: typeof postCode; + cancel?: typeof postCancel; + fetchStatus?: typeof getStatus; +}) { + const [code, setCode] = useState(""); + const [phase, setPhase] = useState("waiting"); + const [note, setNote] = useState(null); + const [seconds, setSeconds] = useState(null); + + const approvalId = output?.approvalId; + const expiresAt = output?.expiresAt; + + useEffect(() => { + if (!approvalId) return; + let alive = true; + fetchStatus(approvalId).then((status) => { + if (!alive) return; + if (status === "verified" || status === "consumed") { + setPhase("approved"); + } else if (status === "voided") { + setPhase("voided"); + } else if (status === "expired") { + setPhase("expired"); + } + }); + return () => { + alive = false; + }; + }, [approvalId, fetchStatus]); + + useEffect(() => { + if (phase !== "waiting" || !expiresAt) return; + const tick = () => + setSeconds( + Math.max(0, Math.floor((Date.parse(expiresAt) - Date.now()) / 1000)), + ); + tick(); + const timer = setInterval(tick, 1000); + return () => clearInterval(timer); + }, [expiresAt, phase]); + + async function submitCode() { + if (!approvalId || code.trim().length < 6) return; + setPhase("checking"); + setNote(null); + const result = await verify(approvalId, code.trim()); + if (result.ok) { + setPhase("approved"); + return; + } + setCode(""); + if (result.reason && result.reason !== "wrong") { + setPhase(result.reason === "expired" ? "expired" : "voided"); + setNote(DEAD_NOTES[result.reason] ?? DEAD_NOTES.gone); + return; + } + setPhase("waiting"); + setNote( + `wrong code${result.attemptsLeft != null ? `, ${result.attemptsLeft} ${result.attemptsLeft === 1 ? "try" : "tries"} left` : ""}`, + ); + } + + const summary = errorText + ? "failed" + : output?.denied + ? "denied" + : output?.totalCents != null + ? `$${(output.totalCents / 100).toFixed(2)} at ${output.storeName ?? ""}` + : ""; + + return ( + + } + summary={ + + {summary} + + } + /> + +
+ {errorText ? ( +

{errorText}

+ ) : output?.denied ? ( +

+ {output.denied} +

+ ) : approvalId ? ( + phase === "approved" ? ( +

+ approved; rendi is placing the order +

+ ) : phase === "cancelled" ? ( +

+ approval cancelled; the cart is open again +

+ ) : phase === "voided" || phase === "expired" ? ( +

+ {note ?? DEAD_NOTES[phase === "expired" ? "expired" : "gone"]} +

+ ) : ( +
+

+ A one-time code is in the owner’s inbox. Type it here; + it never goes through the chat. +

+
+ + setCode(event.target.value.replace(/\D/g, "").slice(0, 6)) + } + onKeyDown={(event) => { + if (event.key === "Enter") void submitCode(); + }} + inputMode="numeric" + autoComplete="one-time-code" + placeholder="six digits" + aria-label="Approval code" + disabled={phase === "checking"} + className="h-9 w-36 text-center font-mono tracking-[0.3em]" + /> + + + {seconds != null ? ( + + {seconds > 0 + ? `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}` + : "expired"} + + ) : null} +
+

+ {note} +

+
+ ) + ) : interrupted ? ( +

+ interrupted +

+ ) : ( + + pricing and sealing the cart + + )} +
+
+
+ ); +} diff --git a/components/doordash/cart-card.stories.tsx b/components/doordash/cart-card.stories.tsx new file mode 100644 index 0000000..daedfac --- /dev/null +++ b/components/doordash/cart-card.stories.tsx @@ -0,0 +1,99 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { expect, fn, userEvent, waitFor, within } from "storybook/test"; +import { cartShowFixture, previewFixture } from "@/lib/rendi/doordash.fixtures"; +import { normalizeCartLines, normalizeQuote } from "@/lib/rendi/doordash-cart"; +import { ddCartEnvelope, ddPreviewResult } from "@/lib/rendi/doordash-schemas"; +import { CartCard, type CartCardData } from "./cart-card"; + +const lines = normalizeCartLines(ddCartEnvelope.parse(cartShowFixture).cart); +const quote = normalizeQuote(ddPreviewResult.parse(previewFixture)); + +const building: CartCardData = { + cartUuid: "cccccccc-1111-2222-3333-444444444444", + storeName: "Toomie's Thai", + storeImageUrl: "https://img.example/toomies.jpg", + items: lines, + quote, + tipCents: 440, + fulfillment: "delivery", +}; + +const meta = { + title: "DoorDash/CartCard", + component: CartCard, + parameters: { layout: "padded" }, + args: { + data: building, + exec: fn(async () => ({ ok: true, cart: null })), + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Building: Story = { + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Pad See Ew")).toBeVisible(); + // Line total: 3 x $18.50. + await expect(canvas.getByText("$55.50")).toBeVisible(); + // The ladder shows the struck-through original beside the free fee. + await expect(canvas.getByText("$0.99")).toBeVisible(); + // Total = total_before_tip + tip: $28.77 + $4.40. + await expect(canvas.getByText("$33.17")).toBeVisible(); + // A stepper touch calls the exec seam with the target quantity. + await userEvent.click( + canvas.getByRole("button", { name: "One more Pad See Ew" }), + ); + await waitFor(() => + expect(args.exec).toHaveBeenCalledWith( + building.cartUuid, + expect.objectContaining({ kind: "set-quantity", quantity: 4 }), + ), + ); + }, +}; + +export const ClosedStore: Story = { + args: { + data: { + ...building, + quote: quote ? { ...quote, asapAvailable: false } : null, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("closed right now")).toBeVisible(); + }, +}; + +export const TipSelection: Story = { + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + // 10% of the $43.98 subtotal, selected by default at birth. + const ten = canvas.getByRole("button", { name: "10%" }); + await expect(ten).toHaveAttribute("aria-pressed", "true"); + await userEvent.click(canvas.getByRole("button", { name: "20%" })); + await waitFor(() => + expect(args.exec).toHaveBeenCalledWith( + building.cartUuid, + expect.objectContaining({ kind: "set-tip", tipCents: 880 }), + ), + ); + }, +}; + +export const Empty: Story = { + args: { data: { ...building, items: [], quote: null } }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("The cart is empty.")).toBeVisible(); + }, +}; diff --git a/components/doordash/cart-card.tsx b/components/doordash/cart-card.tsx new file mode 100644 index 0000000..12177fd --- /dev/null +++ b/components/doordash/cart-card.tsx @@ -0,0 +1,245 @@ +"use client"; + +import { ShoppingBag } from "lucide-react"; +import { useState } from "react"; +import { Shimmer } from "@/components/ai-elements/shimmer"; +import { CartLine } from "@/components/doordash/cart-line"; +import { ZoomableImage } from "@/components/doordash/zoomable-image"; +import { Button } from "@/components/ui/button"; +import { ButtonGroup } from "@/components/ui/button-group"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { DdCartLine, DdQuoteSnapshot } from "@/lib/rendi/doordash-cart"; +import { cn } from "@/lib/utils"; + +export type CartCardData = { + cartUuid: string; + storeId?: string; + storeName: string; + storeImageUrl?: string | null; + items: DdCartLine[]; + quote: DdQuoteSnapshot | null; + tipCents: number; + fulfillment: string; + message?: string | null; +}; + +type CartOp = + | { kind: "set-quantity"; lineId: string; quantity: number } + | { kind: "remove-line"; lineId: string } + | { kind: "set-tip"; tipCents: number } + | { kind: "set-fulfillment"; fulfillment: "delivery" | "pickup" } + | { kind: "preview" }; + +async function execOp( + cartUuid: string, + op: CartOp, +): Promise<{ ok?: boolean; cart?: Partial | null }> { + const response = await fetch(`/api/doordash/carts/${cartUuid}/ops`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(op), + }); + if (!response.ok) throw new Error("edit failed"); + return response.json(); +} + +const TIP_PRESETS = [10, 15, 20]; + +function dollars(cents: number): string { + return `$${(cents / 100).toFixed(2)}`; +} + +// The cart is an instrument: live truth rendered as a card, steered by +// touch without the model, and every change lands where the agent reads +// it back. exec is injectable so stories run on fixtures. +export function CartCard({ + data, + exec = execOp, +}: { + data: CartCardData; + exec?: typeof execOp; +}) { + const [cart, setCart] = useState(data); + const [busy, setBusy] = useState(false); + const [failed, setFailed] = useState(null); + + const subtotal = cart.quote?.ladder.find( + (line) => line.chargeId === "SUBTOTAL", + )?.cents; + + async function touch(op: CartOp, optimistic?: Partial) { + setFailed(null); + setBusy(true); + if (optimistic) setCart((current) => ({ ...current, ...optimistic })); + try { + const result = await exec(cart.cartUuid, op); + if (result.cart) { + setCart((current) => ({ + ...current, + ...(result.cart as Partial), + storeName: + (result.cart as Partial).storeName ?? + current.storeName, + })); + } + } catch { + setFailed("that edit did not go through; prices unchanged"); + setCart(data); + } finally { + setBusy(false); + } + } + + if (cart.items.length === 0) { + return ( + + + + The cart is empty. + + + ); + } + + return ( + + + {cart.storeImageUrl ? ( + + ) : null} +
+ + {cart.storeName} + +

+ {cart.quote?.asapAvailable + ? (cart.quote?.etaRange ?? "") + : "closed right now"} +

+
+ + {(["delivery", "pickup"] as const).map((mode) => ( + + ))} + +
+ +
+ {cart.items.map((line) => ( + + touch({ kind: "set-quantity", lineId, quantity }) + } + onRemove={(lineId) => touch({ kind: "remove-line", lineId })} + /> + ))} +
+
+

Dasher tip

+ + {TIP_PRESETS.map((percent) => { + const cents = subtotal + ? Math.round((subtotal * percent) / 100) + : 0; + const selected = subtotal != null && cents === cart.tipCents; + return ( + + ); + })} + +
+ {cart.quote ? ( +
+ {cart.quote.ladder.map((line) => ( +
+
{line.label}
+
+ {line.originalCents != null ? ( + + {dollars(line.originalCents)} + + ) : null} + {line.displayString} +
+
+ ))} +
+
Dasher tip
+
{dollars(cart.tipCents)}
+
+ {cart.quote.totalBeforeTipCents != null ? ( +
+
Total
+
+ {busy ? ( + settling + ) : ( + dollars(cart.quote.totalBeforeTipCents + cart.tipCents) + )} +
+
+ ) : null} +
+ ) : ( +
+ pricing the cart +
+ )} +

+ {failed} +

+
+
+ ); +} diff --git a/components/doordash/cart-line.tsx b/components/doordash/cart-line.tsx new file mode 100644 index 0000000..4613a8f --- /dev/null +++ b/components/doordash/cart-line.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { Minus, Plus, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { ButtonGroup } from "@/components/ui/button-group"; +import type { DdCartLine } from "@/lib/rendi/doordash-cart"; +import { cn } from "@/lib/utils"; + +export function CartLine({ + line, + busy, + onQuantity, + onRemove, +}: { + line: DdCartLine; + busy: boolean; + onQuantity: (lineId: string, quantity: number) => void; + onRemove: (lineId: string) => void; +}) { + return ( +
+
+

{line.name}

+ {line.options.length > 0 ? ( +

+ {line.options.map((option) => option.name).join(" · ")} +

+ ) : null} +
+ {line.priceCents != null ? ( +

+ ${((line.priceCents * line.quantity) / 100).toFixed(2)} +

+ ) : null} + + + + + + +
+ ); +} diff --git a/components/doordash/cart-tool-card.tsx b/components/doordash/cart-tool-card.tsx new file mode 100644 index 0000000..cddc314 --- /dev/null +++ b/components/doordash/cart-tool-card.tsx @@ -0,0 +1,153 @@ +"use client"; + +import type { ToolUIPart } from "ai"; +import { ShoppingBag } from "lucide-react"; +import { Shimmer } from "@/components/ai-elements/shimmer"; +import { Tool, ToolContent, ToolHeader } from "@/components/ai-elements/tool"; +import { CartCard, type CartCardData } from "@/components/doordash/cart-card"; + +type CartToolOutput = Partial & { + existingCart?: { + cartUuid?: string | null; + storeName?: string | null; + itemsCount?: number | null; + }; + needsChoices?: boolean; + itemErrors?: { + item_name?: string | null; + required_options?: { + name?: string | null; + options?: { name?: string | null }[]; + }[]; + }[]; + deleted?: boolean; + tipCents?: number; +}; + +function Quiet({ + state, + summary, + children, +}: { + state: ToolUIPart["state"]; + summary: string; + children: React.ReactNode; +}) { + return ( + + } + summary={ + + {summary} + + } + /> + +
{children}
+
+
+ ); +} + +export function CartToolCard({ + state, + output, + errorText, + interrupted = false, +}: { + state: ToolUIPart["state"]; + output?: CartToolOutput; + errorText?: string; + interrupted?: boolean; +}) { + if (errorText) { + return ( + +

{errorText}

+
+ ); + } + if (!output) { + return ( + + {interrupted ? ( +

interrupted

+ ) : ( + + reaching DoorDash, up to fifteen seconds + + )} +
+ ); + } + if (output.deleted) { + return ( + +

+ nothing left to order from that cart +

+
+ ); + } + if (output.existingCart) { + return ( + +

+ an open cart already exists at {output.existingCart.storeName} + {output.existingCart.itemsCount + ? ` with ${output.existingCart.itemsCount} item${output.existingCart.itemsCount === 1 ? "" : "s"}` + : ""} + ; rendi is asking whether to extend or replace it +

+
+ ); + } + if (output.needsChoices) { + return ( + +
+ {(output.itemErrors ?? []).map((item, index) => ( +
+

{item.item_name}

+ {(item.required_options ?? []).map((group) => ( +

+ {group.name}:{" "} + {(group.options ?? []) + .map((option) => option.name) + .filter(Boolean) + .join(" / ")} +

+ ))} +
+ ))} +

+ tell rendi which, and it will add the item +

+
+
+ ); + } + if (output.cartUuid && output.items) { + return ; + } + if (output.cartUuid && output.tipCents !== undefined) { + return ( + +

+ dasher tip is now ${(output.tipCents / 100).toFixed(2)} +

+
+ ); + } + return null; +} diff --git a/components/doordash/doordash-browse-card.stories.tsx b/components/doordash/doordash-browse-card.stories.tsx new file mode 100644 index 0000000..4afa8dc --- /dev/null +++ b/components/doordash/doordash-browse-card.stories.tsx @@ -0,0 +1,97 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { expect, userEvent, waitFor, within } from "storybook/test"; +import { + itemDetailsFixture, + searchFixture, +} from "@/lib/rendi/doordash.fixtures"; +import { ddItemDetails, ddSearchResult } from "@/lib/rendi/doordash-schemas"; +import { DoorDashBrowseCard } from "./doordash-browse-card"; + +const meta = { + title: "DoorDash/BrowseCard", + component: DoorDashBrowseCard, + parameters: { layout: "padded" }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const stores = ddSearchResult.parse(searchFixture); +const details = ddItemDetails.parse(itemDetailsFixture); + +export const StorePicks: Story = { + args: { + state: "output-available", + input: { verb: "search", query: "ramen near me" }, + output: stores, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Itani Ramen")).toBeVisible(); + // Meters arrive raw; the card speaks miles. + await expect(canvas.getByText(/1\.1 mi/)).toBeVisible(); + // The second store has no image and must hold its layout. + await expect(canvas.getByText("Sobo Ramen")).toBeVisible(); + }, +}; + +export const ItemSpotlight: Story = { + args: { + state: "output-available", + input: { verb: "item-details", storeId: "55382" }, + output: details, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Once in the header summary, once in the spotlight body. + await expect(canvas.getAllByText("Tonkotsu Ramen")).toHaveLength(2); + await expect(canvas.getByText("$16.50")).toBeVisible(); + await expect(canvas.getByText(/often: Add Extra Egg/)).toBeVisible(); + // Thumbnails open to a proper look; escape puts it away. + await userEvent.click( + canvas.getByRole("button", { name: /View larger: Tonkotsu Ramen/ }), + ); + const dialog = await waitFor(() => { + const found = document.querySelector("[role='dialog']"); + expect(found).not.toBeNull(); + return found as HTMLElement; + }); + await waitFor(() => + expect(within(dialog).getByAltText("Tonkotsu Ramen")).toBeVisible(), + ); + await userEvent.keyboard("{Escape}"); + await waitFor(() => + expect(document.querySelector("[role='dialog']")).toBeNull(), + ); + }, +}; + +export const Reaching: Story = { + args: { + state: "input-available", + input: { verb: "search", query: "sushi" }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText(/reaching DoorDash/)).toBeVisible(); + }, +}; + +export const Failed: Story = { + args: { + state: "output-error", + input: { verb: "menu", storeId: "1" }, + errorText: "Store is closed", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Store is closed")).toBeVisible(); + }, +}; diff --git a/components/doordash/doordash-browse-card.tsx b/components/doordash/doordash-browse-card.tsx new file mode 100644 index 0000000..c653fe1 --- /dev/null +++ b/components/doordash/doordash-browse-card.tsx @@ -0,0 +1,242 @@ +"use client"; + +import type { ToolUIPart } from "ai"; +import { + CreditCard, + MapPin, + ReceiptText, + Store, + UtensilsCrossed, +} from "lucide-react"; +import { Shimmer } from "@/components/ai-elements/shimmer"; +import { Tool, ToolContent, ToolHeader } from "@/components/ai-elements/tool"; +import { ZoomableImage } from "@/components/doordash/zoomable-image"; +import type { DdMenuItem, DdStore } from "@/lib/rendi/doordash-schemas"; +import { cn } from "@/lib/utils"; + +type Verb = + | "search" + | "nearby-stores" + | "menu" + | "store-details" + | "item-details" + | "find-items" + | "order-history" + | "order-receipt" + | "addresses" + | "payment-methods"; + +type BrowseInput = { verb?: Verb; query?: string; storeId?: string }; +type BrowseOutput = { + stores?: DdStore[]; + items?: DdMenuItem[]; + store_name?: string | null; + item?: { + name: string; + description?: string | null; + image_url?: string | null; + price?: number | null; + has_required_modifiers?: boolean; + popular_modifications?: { description: string }[]; + }; + orders?: { store_name?: string | null }[]; + addresses?: unknown[]; + cards?: unknown[]; + message?: string | null; +}; + +const TITLES: Record = { + search: "Searched DoorDash", + "nearby-stores": "Found nearby stores", + menu: "Read the menu", + "store-details": "Looked up the store", + "item-details": "Looked at an item", + "find-items": "Searched the aisles", + "order-history": "Read order history", + "order-receipt": "Pulled a receipt", + addresses: "Read saved addresses", + "payment-methods": "Read saved cards", +}; + +const ICONS: Partial> = { + search: UtensilsCrossed, + "nearby-stores": Store, + "store-details": MapPin, + "order-receipt": ReceiptText, + "payment-methods": CreditCard, +}; + +function miles(meters?: number | null): string | null { + if (meters == null) return null; + return `${(meters / 1609).toFixed(1)} mi`; +} + +function StoreTile({ store }: { store: DdStore }) { + const distance = miles(store.distance_meters); + return ( +
+ {store.image_url ? ( + + ) : ( +
+ +
+ )} +
+

{store.name}

+

+ {[ + store.rating != null ? `${store.rating.toFixed(1)}★` : null, + store.delivery_time, + distance, + ] + .filter(Boolean) + .join(" · ")} +

+ {store.printable_address ? ( +

+ {store.printable_address} +

+ ) : null} +
+
+ ); +} + +function ItemSpotlight({ item }: { item: NonNullable }) { + return ( +
+ {item.image_url ? ( + + ) : null} +
+
+

{item.name}

+ {item.price != null ? ( +

+ ${item.price.toFixed(2)} +

+ ) : null} +
+ {item.description ? ( +

+ {item.description} +

+ ) : null} +
+ {item.has_required_modifiers ? ( + + needs choices + + ) : null} + {item.popular_modifications?.[0] ? ( + + often: {item.popular_modifications[0].description} + + ) : null} +
+
+
+ ); +} + +function summarize(verb: Verb | undefined, output?: BrowseOutput): string { + switch (verb) { + case "search": + case "nearby-stores": + return `${output?.stores?.length ?? 0} stores`; + case "menu": + return `${output?.items?.length ?? 0} items${output?.store_name ? ` · ${output.store_name}` : ""}`; + case "item-details": + return output?.item?.name ?? ""; + case "order-history": + return `${output?.orders?.length ?? 0} orders`; + case "addresses": + return `${output?.addresses?.length ?? 0} saved`; + case "payment-methods": + return `${output?.cards?.length ?? 0} on file`; + default: + return ""; + } +} + +export function DoorDashBrowseCard({ + state, + input, + output, + errorText, + interrupted = false, +}: { + state: ToolUIPart["state"]; + input?: BrowseInput; + output?: BrowseOutput; + errorText?: string; + interrupted?: boolean; +}) { + const verb = input?.verb; + const Icon = (verb && ICONS[verb]) || UtensilsCrossed; + const stores = + verb === "search" || verb === "nearby-stores" ? (output?.stores ?? []) : []; + return ( + + } + summary={ + + {errorText + ? "failed" + : output + ? summarize(verb, output) + : (input?.query ?? "")} + + } + /> + +
+ {errorText ? ( +

{errorText}

+ ) : output ? ( + stores.length > 0 ? ( +
+ {stores.map((store) => ( + + ))} +
+ ) : verb === "item-details" && output.item ? ( + + ) : ( +

+ {output.message?.trim() || + `${summarize(verb, output)} · in hand`} +

+ ) + ) : interrupted ? ( +

+ interrupted +

+ ) : ( + + reaching DoorDash, about six seconds + + )} +
+
+
+ ); +} diff --git a/components/doordash/order-card.stories.tsx b/components/doordash/order-card.stories.tsx new file mode 100644 index 0000000..35824e7 --- /dev/null +++ b/components/doordash/order-card.stories.tsx @@ -0,0 +1,134 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { expect, within } from "storybook/test"; +import { OrderCard } from "./order-card"; + +const meta = { + title: "DoorDash/OrderCard", + component: OrderCard, + parameters: { layout: "padded" }, + args: { state: "output-available" }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Placed: Story = { + args: { + output: { + outcome: "successful", + orderUuid: "abcdef12-3456-7890-abcd-ef1234567890", + totalCents: 3317, + storeName: "Toomie's Thai", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByText("the order is in at Toomie's Thai for $33.17"), + ).toBeVisible(); + await expect(canvas.getByText("order 34567890")).toBeVisible(); + }, +}; + +export const Failed: Story = { + args: { + output: { + outcome: "failed", + error: "payment method declined", + note: "never resubmit; the app or browser checkout finishes this", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("payment method declined")).toBeVisible(); + await expect( + canvas.getByText( + "never resubmit; the app or browser checkout finishes this", + ), + ).toBeVisible(); + }, +}; + +export const ActionRequired: Story = { + args: { + output: { + outcome: "action_required", + orderUuid: "abcdef12-3456-7890-abcd-ef1234567890", + totalCents: 3317, + storeName: "Toomie's Thai", + note: "never resubmit; the app or browser checkout finishes this", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByText("DoorDash wants something only the app can answer"), + ).toBeVisible(); + }, +}; + +export const Held: Story = { + args: { + output: { refused: "the code was never entered" }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("the code was never entered")).toBeVisible(); + }, +}; + +export const Unconfirmed: Story = { + args: { + output: { + outcome: "unknown", + storeName: "Toomie's Thai", + note: "the submission may or may not have reached DoorDash; never resubmit and never check out elsewhere until order history answers. Check the order history in a minute and reconcile honestly.", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("The order is unconfirmed")).toBeVisible(); + }, +}; + +export const NotFound: Story = { + args: { + output: { + outcome: "not_found", + orderUuid: "abcdef12-3456-7890-abcd-ef1234567890", + storeName: "Toomie's Thai", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByText( + "DoorDash cannot find this order; check the app before doing anything else", + ), + ).toBeVisible(); + }, +}; + +export const Voided: Story = { + args: { + output: { + voided: + "the cart changed after the code was sent; request a fresh approval", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByText( + "the cart changed after the code was sent; request a fresh approval", + ), + ).toBeVisible(); + }, +}; diff --git a/components/doordash/order-card.tsx b/components/doordash/order-card.tsx new file mode 100644 index 0000000..eace45a --- /dev/null +++ b/components/doordash/order-card.tsx @@ -0,0 +1,202 @@ +"use client"; + +import type { ToolUIPart } from "ai"; +import { ReceiptText } from "lucide-react"; +import { Shimmer } from "@/components/ai-elements/shimmer"; +import { Tool, ToolContent, ToolHeader } from "@/components/ai-elements/tool"; + +type OrderOutput = { + outcome?: string; + orderUuid?: string | null; + totalCents?: number; + storeName?: string; + error?: string; + note?: string; + refused?: string; + voided?: string; +}; + +function face(output: OrderOutput): { + title: string; + summary: string; + tone: "good" | "bad" | "muted"; + lines: string[]; +} { + if (output.refused) { + return { + title: "Held the order", + summary: "refused", + tone: "muted", + lines: [output.refused], + }; + } + if (output.voided) { + return { + title: "Held the order", + summary: "voided", + tone: "muted", + lines: [output.voided], + }; + } + const total = + output.totalCents != null + ? `$${(output.totalCents / 100).toFixed(2)}` + : null; + const where = output.storeName ?? "the store"; + switch (output.outcome) { + case "successful": + return { + title: "Placed the order", + summary: [total, where].filter(Boolean).join(" at "), + tone: "good", + lines: [ + `the order is in at ${where}${total ? ` for ${total}` : ""}`, + ...(output.orderUuid ? [`order ${output.orderUuid.slice(-8)}`] : []), + ], + }; + case "pending": + return { + title: "Placing the order", + summary: [total, where].filter(Boolean).join(" at "), + tone: "muted", + lines: [ + "DoorDash accepted the order and is still confirming", + ...(output.note ? [output.note] : []), + ], + }; + case "action_required": + return { + title: "The order needs a human", + summary: "action required", + tone: "bad", + lines: [ + "DoorDash wants something only the app can answer", + ...(output.error ? [output.error] : []), + ...(output.note ? [output.note] : []), + ], + }; + case "cancelled": + return { + title: "The order was cancelled", + summary: "cancelled", + tone: "muted", + lines: [ + `DoorDash cancelled the order at ${where}`, + ...(output.error ? [output.error] : []), + ], + }; + case "failed": + return { + title: "The order failed", + summary: "failed", + tone: "bad", + lines: [ + ...(output.error ? [output.error] : ["DoorDash rejected the order"]), + ...(output.note ? [output.note] : []), + ], + }; + case "unknown": + return { + title: "The order is unconfirmed", + summary: "unconfirmed", + tone: "bad", + lines: [ + "the submission may or may not have reached DoorDash", + ...(output.note ? [output.note] : []), + ], + }; + case "not_found": + return { + title: "The order is unaccounted for", + summary: "not found", + tone: "bad", + lines: [ + "DoorDash cannot find this order; check the app before doing anything else", + ...(output.error ? [output.error] : []), + ], + }; + default: + return { + title: "Placed the order", + summary: output.outcome ?? "", + tone: "muted", + lines: [ + ...(output.note ? [output.note] : []), + ...(output.error ? [output.error] : []), + ], + }; + } +} + +// The submit tool's face: the receipt moment when it lands, and an +// equally plain telling when it does not. +export function OrderCard({ + state, + output, + errorText, + interrupted = false, +}: { + state: ToolUIPart["state"]; + output?: OrderOutput; + errorText?: string; + interrupted?: boolean; +}) { + const resolved = errorText + ? { + title: "The order failed", + summary: "failed", + tone: "bad" as const, + lines: [errorText], + } + : output + ? face(output) + : null; + + return ( + + } + summary={ + + {resolved?.summary ?? ""} + + } + /> + +
+ {resolved ? ( +
+ {resolved.lines.map((line, index) => ( +

+ {line} +

+ ))} +
+ ) : interrupted ? ( +

+ interrupted +

+ ) : ( + + placing the order; confirmation can take a minute + + )} +
+
+
+ ); +} diff --git a/components/doordash/zoomable-image.tsx b/components/doordash/zoomable-image.tsx new file mode 100644 index 0000000..443d7c2 --- /dev/null +++ b/components/doordash/zoomable-image.tsx @@ -0,0 +1,58 @@ +"use client"; + +import Image from "next/image"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; + +// Food is visual: every thumbnail in a DoorDash card opens to a proper +// look. The thumb is the trigger; the dialog shows the same source at +// its natural aspect. +export function ZoomableImage({ + src, + name, + className, +}: { + src: string; + name: string; + className?: string; +}) { + return ( + + + + + + {name} + + Larger view of {name} + + {/* Plain img: a lightbox of an arbitrary remote wants the natural + aspect, which next/image cannot give without knowing it. */} + {name} + + + ); +} diff --git a/docs/AGENT_DEPLOY.md b/docs/AGENT_DEPLOY.md index 1d4f409..a3d4a3b 100644 --- a/docs/AGENT_DEPLOY.md +++ b/docs/AGENT_DEPLOY.md @@ -43,6 +43,13 @@ Optional, ask which the user wants: limits and private repositories. - [ ] `ACCESS_CODES` plus `ACCESS_TOKEN_SECRET` if the deployed instance should sit behind an access-code gate. +- [ ] DoorDash ordering, only if the user has dd-cli access (it is waitlisted) and + wants it: `RENDI_OWNER_EMAIL` (the approval inbox; Resend becomes required), + `DD_CLI_ACCESS_TOKEN` for production (the user runs `dd-cli export-token` + themselves; never ask them to paste it into chat), and optionally + `DD_MAX_ORDER_CENTS`, `DD_MAX_ORDERS_PER_DAY`, `DD_HIDE_PERSONAL=1`. If the + user does not want ordering, delete the four `doordash-*` lines from + `trigger/rendi/agent.md`; that file is then the third permitted edit. ## Stage 1: local bring-up @@ -108,6 +115,13 @@ window. **Check:** the shared board renders without cookies and its charts execute live. Report the production URL, the share link, and anything skipped back to the user. +If DoorDash is configured, prove the approval machine without spending: ask the +agent to build a small cart and request approval. **Check:** a code email reaches +`RENDI_OWNER_EMAIL` and the order card grows a code input. Do not enter the code +and do not place an order unless the user explicitly asks for a real one; a wrong +code (expect "wrong code, 4 tries left") plus cancel proves the machinery and +costs nothing. + ## When something fails The traps list at the end of [DEPLOYMENT.md](DEPLOYMENT.md) covers the failures we diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 20ebae8..b761863 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -76,6 +76,37 @@ deployed with. If an AI assistant is driving the deploy for you, hand it open, mint a share link and open it in a private window (it must render without cookies), and send yourself an email if Resend is configured. +## DoorDash ordering (optional skin) + +The agent can browse, build carts, and place real orders through the official +[DoorDash CLI](https://github.com/door-dash/dd-cli), against a personal account, +which is what their terms allow. It is off unless configured; to run without it +entirely, delete the four `doordash-*` lines from `trigger/rendi/agent.md` and +the tools disappear from the agent's hands. + +Money is gated structurally, not by prompt: placing an order requires a +six-digit code that is emailed to `RENDI_OWNER_EMAIL` and typed into the order +card, never into the chat. The approval is single-use, expires in fifteen +minutes, dies after five wrong guesses, and is hash-bound to the exact +previewed cart, so any change after the email voids it. Spend caps +(`DD_MAX_ORDER_CENTS`, `DD_MAX_ORDERS_PER_DAY`) are enforced below the agent. +Whoever owns the inbox owns every order, which is the point: you can hand out +gate codes and let strangers drive the agent, and nobody can spend a cent +without you. For that posture set `DD_HIDE_PERSONAL=1` so saved addresses, +payment methods, and order history stay invisible to the agent too. + +Setup: + +1. Get CLI access from DoorDash (it is waitlisted) and install `dd-cli`. +2. Local: `dd-cli login` opens the browser once and keeps the session in the + OS keychain. Set `RENDI_OWNER_EMAIL`; Resend must be configured, since the + codes ride email. +3. Production: workers are headless, so mint a token with `dd-cli export-token` + on a machine where you are signed in and set it as `DD_CLI_ACCESS_TOKEN` in + the Trigger.dev environment, alongside the same DoorDash variables. The + worker image needs the `dd-cli` binary on its PATH; `trigger.config.ts` + handles that at deploy. + ## Environment variables | Name | Required | Surfaces | What it is | @@ -97,6 +128,12 @@ deployed with. If an AI assistant is driving the deploy for you, hand it | `GITHUB_TOKEN` | optional | local, Trigger | Zero-scope token for the commit sync: higher rate limits, private repos. | | `ACCESS_CODES`, `ACCESS_TOKEN_SECRET` | optional | Vercel | The access gate; unset means no gate. | | `CLICKSTACK_OTLP_URL` | optional | local | Dual-emit telemetry spans to a ClickStack OTLP collector. Unset means off. | +| `RENDI_OWNER_EMAIL` | with DoorDash | local, Trigger | The approval inbox. Every order needs a one-time code from it. | +| `DD_CLI_ACCESS_TOKEN` | with DoorDash | Trigger | Headless auth for deployed workers, from `dd-cli export-token`. Local dev uses `dd-cli login` instead. | +| `DD_MAX_ORDER_CENTS` | defaults to `10000` | local, Trigger | Order ceiling in cents, enforced below the agent. | +| `DD_MAX_ORDERS_PER_DAY` | defaults to `3` | local, Trigger | Daily order budget. | +| `DD_MAX_APPROVALS_PER_DAY` | defaults to `15` | local, Trigger | Daily budget of approval requests (code emails). Widen it when many guests share the instance. | +| `DD_HIDE_PERSONAL` | optional | local, Trigger | `1` hides saved addresses, payment methods, and order history from the agent; for instances strangers can drive. | ## Traps we hit, so you do not have to @@ -113,3 +150,6 @@ deployed with. If an AI assistant is driving the deploy for you, hand it - Git backfills from partial clones read commit objects only, so the seed script never computes diffs (`--shortstat` would fail on blobless clones). Churn columns fill in through the API sync. +- `trigger dev` reloads code on save but never the env file. After editing + `.env.development.local`, restart the dev worker or the tasks keep the old + values. diff --git a/drizzle/0008_conversation-trigger-env.sql b/drizzle/0008_conversation-trigger-env.sql new file mode 100644 index 0000000..4531cc0 --- /dev/null +++ b/drizzle/0008_conversation-trigger-env.sql @@ -0,0 +1 @@ +ALTER TABLE "conversations" ADD COLUMN "trigger_env" text DEFAULT 'prod' NOT NULL; \ No newline at end of file diff --git a/drizzle/0009_backfill-birth-environments.sql b/drizzle/0009_backfill-birth-environments.sql new file mode 100644 index 0000000..a9836ea --- /dev/null +++ b/drizzle/0009_backfill-birth-environments.sql @@ -0,0 +1,9 @@ +-- Custom migration (data, not schema): rows older than the production +-- cutover were born in the dev environment, the fact the replaced +-- archive logic encoded in code. Recorded here so any replay of these +-- migrations reconstructs provenance instead of defaulting history to +-- prod. Idempotent. +UPDATE "conversations" +SET "trigger_env" = 'dev' +WHERE "created_at" < '2026-07-22T20:40:00+00' + AND "trigger_env" <> 'dev'; diff --git a/drizzle/0010_doordash.sql b/drizzle/0010_doordash.sql new file mode 100644 index 0000000..4bd9a16 --- /dev/null +++ b/drizzle/0010_doordash.sql @@ -0,0 +1,53 @@ +CREATE TABLE "dd_approvals" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "dd_approvals_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "conversation_id" text NOT NULL, + "cart_uuid" text NOT NULL, + "cart_hash" text NOT NULL, + "total_cents" integer NOT NULL, + "tip_cents" integer NOT NULL, + "code_hash" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "attempts" integer DEFAULT 0 NOT NULL, + "verified_at" timestamp with time zone, + "consumed_at" timestamp with time zone, + "voided_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "dd_carts" ( + "cart_uuid" text PRIMARY KEY NOT NULL, + "conversation_id" text NOT NULL, + "store_id" text NOT NULL, + "store_name" text NOT NULL, + "store_image_url" text, + "items" jsonb NOT NULL, + "quote" jsonb, + "fulfillment" text DEFAULT 'delivery' NOT NULL, + "scheduled_time" text, + "tip_cents" integer DEFAULT 0 NOT NULL, + "status" text DEFAULT 'open' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "dd_orders" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "dd_orders_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "approval_id" integer NOT NULL, + "conversation_id" text NOT NULL, + "cart_uuid" text NOT NULL, + "order_uuid" text, + "store_name" text NOT NULL, + "total_cents" integer NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "error_message" text, + "receipt" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "dd_orders_approval_id_unique" UNIQUE("approval_id") +); +--> statement-breakpoint +ALTER TABLE "dd_carts" ADD CONSTRAINT "dd_carts_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "dd_orders" ADD CONSTRAINT "dd_orders_approval_id_dd_approvals_id_fk" FOREIGN KEY ("approval_id") REFERENCES "public"."dd_approvals"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "dd_approvals_cart_idx" ON "dd_approvals" USING btree ("cart_uuid");--> statement-breakpoint +CREATE INDEX "dd_carts_conversation_idx" ON "dd_carts" USING btree ("conversation_id");--> statement-breakpoint +CREATE INDEX "dd_orders_created_idx" ON "dd_orders" USING btree ("created_at"); \ No newline at end of file diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..1208105 --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,893 @@ +{ + "id": "d821d65b-1e75-421e-9c80-32c9e80aa00f", + "prevId": "1ebd0d4f-312f-44ed-9a2d-28de124c8a08", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.canvas_ops": { + "name": "canvas_ops", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "canvas_ops_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "canvas_id": { + "name": "canvas_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor": { + "name": "actor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entry": { + "name": "entry", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "seen_turn": { + "name": "seen_turn", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "canvas_ops_unseen_idx": { + "name": "canvas_ops_unseen_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seen_turn", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.canvases": { + "name": "canvases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Canvas'" + }, + "doc": { + "name": "doc", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "canvases_conversation_id_conversations_id_fk": { + "name": "canvases_conversation_id_conversations_id_fk", + "tableFrom": "canvases", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "canvases_conversation_id_unique": { + "name": "canvases_conversation_id_unique", + "nullsNotDistinct": false, + "columns": [ + "conversation_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New conversation'" + }, + "public_access_token": { + "name": "public_access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_event_id": { + "name": "last_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "turns": { + "name": "turns", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "trigger_env": { + "name": "trigger_env", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'prod'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.datasets": { + "name": "datasets", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_name": { + "name": "table_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rows_loaded": { + "name": "rows_loaded", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_estimate": { + "name": "rows_estimate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.emails": { + "name": "emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "emails_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to": { + "name": "to", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resend_id": { + "name": "resend_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emails_conversation_idx": { + "name": "emails_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.images": { + "name": "images", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'generated'" + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime": { + "name": "mime", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "images_conversation_idx": { + "name": "images_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "images_conversation_id_conversations_id_fk": { + "name": "images_conversation_id_conversations_id_fk", + "tableFrom": "images", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instrument_ops": { + "name": "instrument_ops", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "instrument_ops_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instrument_id": { + "name": "instrument_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor": { + "name": "actor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "param": { + "name": "param", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "old_value": { + "name": "old_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seen_turn": { + "name": "seen_turn", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "instrument_ops_unseen_idx": { + "name": "instrument_ops_unseen_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seen_turn", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instruments": { + "name": "instruments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sql": { + "name": "sql", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "present": { + "name": "present", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "current_values": { + "name": "current_values", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "instruments_conversation_id_conversations_id_fk": { + "name": "instruments_conversation_id_conversations_id_fk", + "tableFrom": "instruments", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "turn": { + "name": "turn", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "messages_conversation_id_id_pk": { + "name": "messages_conversation_id_id_pk", + "columns": [ + "conversation_id", + "id" + ] + } + }, + "uniqueConstraints": { + "messages_conversation_id_position_unique": { + "name": "messages_conversation_id_position_unique", + "nullsNotDistinct": false, + "columns": [ + "conversation_id", + "position" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pulses": { + "name": "pulses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "beats": { + "name": "beats", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_beat_at": { + "name": "last_beat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pulses_conversation_idx": { + "name": "pulses_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pulses_conversation_id_conversations_id_fk": { + "name": "pulses_conversation_id_conversations_id_fk", + "tableFrom": "pulses", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0009_snapshot.json b/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..57cb049 --- /dev/null +++ b/drizzle/meta/0009_snapshot.json @@ -0,0 +1,893 @@ +{ + "id": "794ec787-b77d-4250-862a-3496848a2c4c", + "prevId": "d821d65b-1e75-421e-9c80-32c9e80aa00f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.canvas_ops": { + "name": "canvas_ops", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "name": "canvas_ops_id_seq", + "increment": "1", + "minValue": "1", + "maxValue": "2147483647", + "startWith": "1", + "cache": "1", + "cycle": false, + "schema": "public", + "type": "always" + } + }, + "canvas_id": { + "name": "canvas_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor": { + "name": "actor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entry": { + "name": "entry", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "seen_turn": { + "name": "seen_turn", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "canvas_ops_unseen_idx": { + "name": "canvas_ops_unseen_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seen_turn", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.canvases": { + "name": "canvases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Canvas'" + }, + "doc": { + "name": "doc", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "canvases_conversation_id_conversations_id_fk": { + "name": "canvases_conversation_id_conversations_id_fk", + "tableFrom": "canvases", + "columnsFrom": [ + "conversation_id" + ], + "tableTo": "conversations", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "canvases_conversation_id_unique": { + "name": "canvases_conversation_id_unique", + "columns": [ + "conversation_id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New conversation'" + }, + "public_access_token": { + "name": "public_access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_event_id": { + "name": "last_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "turns": { + "name": "turns", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "trigger_env": { + "name": "trigger_env", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'prod'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.datasets": { + "name": "datasets", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_name": { + "name": "table_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rows_loaded": { + "name": "rows_loaded", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_estimate": { + "name": "rows_estimate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.emails": { + "name": "emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "name": "emails_id_seq", + "increment": "1", + "minValue": "1", + "maxValue": "2147483647", + "startWith": "1", + "cache": "1", + "cycle": false, + "schema": "public", + "type": "always" + } + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to": { + "name": "to", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resend_id": { + "name": "resend_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emails_conversation_idx": { + "name": "emails_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.images": { + "name": "images", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'generated'" + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime": { + "name": "mime", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "images_conversation_idx": { + "name": "images_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "images_conversation_id_conversations_id_fk": { + "name": "images_conversation_id_conversations_id_fk", + "tableFrom": "images", + "columnsFrom": [ + "conversation_id" + ], + "tableTo": "conversations", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instrument_ops": { + "name": "instrument_ops", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "name": "instrument_ops_id_seq", + "increment": "1", + "minValue": "1", + "maxValue": "2147483647", + "startWith": "1", + "cache": "1", + "cycle": false, + "schema": "public", + "type": "always" + } + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instrument_id": { + "name": "instrument_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor": { + "name": "actor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "param": { + "name": "param", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "old_value": { + "name": "old_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seen_turn": { + "name": "seen_turn", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "instrument_ops_unseen_idx": { + "name": "instrument_ops_unseen_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seen_turn", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instruments": { + "name": "instruments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sql": { + "name": "sql", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "present": { + "name": "present", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "current_values": { + "name": "current_values", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "instruments_conversation_id_conversations_id_fk": { + "name": "instruments_conversation_id_conversations_id_fk", + "tableFrom": "instruments", + "columnsFrom": [ + "conversation_id" + ], + "tableTo": "conversations", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "turn": { + "name": "turn", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "columnsFrom": [ + "conversation_id" + ], + "tableTo": "conversations", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": { + "messages_conversation_id_id_pk": { + "name": "messages_conversation_id_id_pk", + "columns": [ + "conversation_id", + "id" + ] + } + }, + "uniqueConstraints": { + "messages_conversation_id_position_unique": { + "name": "messages_conversation_id_position_unique", + "columns": [ + "conversation_id", + "position" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pulses": { + "name": "pulses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "beats": { + "name": "beats", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_beat_at": { + "name": "last_beat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pulses_conversation_idx": { + "name": "pulses_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "pulses_conversation_id_conversations_id_fk": { + "name": "pulses_conversation_id_conversations_id_fk", + "tableFrom": "pulses", + "columnsFrom": [ + "conversation_id" + ], + "tableTo": "conversations", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0010_snapshot.json b/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..40b1b17 --- /dev/null +++ b/drizzle/meta/0010_snapshot.json @@ -0,0 +1,1276 @@ +{ + "id": "c81ce776-22a3-4933-96cb-28cc2d3af1e8", + "prevId": "794ec787-b77d-4250-862a-3496848a2c4c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.canvas_ops": { + "name": "canvas_ops", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "canvas_ops_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "canvas_id": { + "name": "canvas_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor": { + "name": "actor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entry": { + "name": "entry", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "seen_turn": { + "name": "seen_turn", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "canvas_ops_unseen_idx": { + "name": "canvas_ops_unseen_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seen_turn", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.canvases": { + "name": "canvases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Canvas'" + }, + "doc": { + "name": "doc", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "canvases_conversation_id_conversations_id_fk": { + "name": "canvases_conversation_id_conversations_id_fk", + "tableFrom": "canvases", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "canvases_conversation_id_unique": { + "name": "canvases_conversation_id_unique", + "nullsNotDistinct": false, + "columns": [ + "conversation_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New conversation'" + }, + "public_access_token": { + "name": "public_access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_event_id": { + "name": "last_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "turns": { + "name": "turns", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "trigger_env": { + "name": "trigger_env", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'prod'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.datasets": { + "name": "datasets", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_name": { + "name": "table_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rows_loaded": { + "name": "rows_loaded", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_estimate": { + "name": "rows_estimate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dd_approvals": { + "name": "dd_approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "dd_approvals_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cart_uuid": { + "name": "cart_uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cart_hash": { + "name": "cart_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_cents": { + "name": "total_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tip_cents": { + "name": "tip_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "voided_at": { + "name": "voided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "dd_approvals_cart_idx": { + "name": "dd_approvals_cart_idx", + "columns": [ + { + "expression": "cart_uuid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dd_carts": { + "name": "dd_carts", + "schema": "", + "columns": { + "cart_uuid": { + "name": "cart_uuid", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "store_id": { + "name": "store_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "store_name": { + "name": "store_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "store_image_url": { + "name": "store_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "items": { + "name": "items", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "quote": { + "name": "quote", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "fulfillment": { + "name": "fulfillment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'delivery'" + }, + "scheduled_time": { + "name": "scheduled_time", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tip_cents": { + "name": "tip_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "dd_carts_conversation_idx": { + "name": "dd_carts_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dd_carts_conversation_id_conversations_id_fk": { + "name": "dd_carts_conversation_id_conversations_id_fk", + "tableFrom": "dd_carts", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dd_orders": { + "name": "dd_orders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "dd_orders_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "approval_id": { + "name": "approval_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cart_uuid": { + "name": "cart_uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order_uuid": { + "name": "order_uuid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "store_name": { + "name": "store_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_cents": { + "name": "total_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "receipt": { + "name": "receipt", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "dd_orders_created_idx": { + "name": "dd_orders_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dd_orders_approval_id_dd_approvals_id_fk": { + "name": "dd_orders_approval_id_dd_approvals_id_fk", + "tableFrom": "dd_orders", + "tableTo": "dd_approvals", + "columnsFrom": [ + "approval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dd_orders_approval_id_unique": { + "name": "dd_orders_approval_id_unique", + "nullsNotDistinct": false, + "columns": [ + "approval_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.emails": { + "name": "emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "emails_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to": { + "name": "to", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resend_id": { + "name": "resend_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emails_conversation_idx": { + "name": "emails_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.images": { + "name": "images", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'generated'" + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime": { + "name": "mime", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "images_conversation_idx": { + "name": "images_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "images_conversation_id_conversations_id_fk": { + "name": "images_conversation_id_conversations_id_fk", + "tableFrom": "images", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instrument_ops": { + "name": "instrument_ops", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "instrument_ops_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instrument_id": { + "name": "instrument_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor": { + "name": "actor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "param": { + "name": "param", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "old_value": { + "name": "old_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seen_turn": { + "name": "seen_turn", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "instrument_ops_unseen_idx": { + "name": "instrument_ops_unseen_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seen_turn", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instruments": { + "name": "instruments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sql": { + "name": "sql", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "present": { + "name": "present", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "current_values": { + "name": "current_values", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "instruments_conversation_id_conversations_id_fk": { + "name": "instruments_conversation_id_conversations_id_fk", + "tableFrom": "instruments", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "turn": { + "name": "turn", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "messages_conversation_id_id_pk": { + "name": "messages_conversation_id_id_pk", + "columns": [ + "conversation_id", + "id" + ] + } + }, + "uniqueConstraints": { + "messages_conversation_id_position_unique": { + "name": "messages_conversation_id_position_unique", + "nullsNotDistinct": false, + "columns": [ + "conversation_id", + "position" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pulses": { + "name": "pulses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "beats": { + "name": "beats", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_beat_at": { + "name": "last_beat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pulses_conversation_idx": { + "name": "pulses_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pulses_conversation_id_conversations_id_fk": { + "name": "pulses_conversation_id_conversations_id_fk", + "tableFrom": "pulses", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 448c883..167ff29 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,27 @@ "when": 1784741129586, "tag": "0007_emails", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1786424565324, + "tag": "0008_conversation-trigger-env", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1786426983300, + "tag": "0009_backfill-birth-environments", + "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1786427902394, + "tag": "0010_doordash", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/db/queries.test.ts b/lib/db/queries.test.ts new file mode 100644 index 0000000..f0f4529 --- /dev/null +++ b/lib/db/queries.test.ts @@ -0,0 +1,48 @@ +import type { drizzle } from "drizzle-orm/pglite"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createTestDb } from "../rendi/harness/test-db.ts"; +import { pageConversations, searchConversations } from "./queries.ts"; + +const holder = vi.hoisted(() => ({ db: undefined as unknown })); + +vi.mock("./index.ts", () => ({ + getDb: () => holder.db, +})); + +const ORIGINAL = process.env.TRIGGER_SECRET_KEY; + +beforeEach(async () => { + holder.db = await createTestDb(); + const db = holder.db as ReturnType; + await db.execute(` + insert into conversations (id, title, trigger_env, updated_at) values + ('p-1', 'Prod keeper', 'prod', now()), + ('d-1', 'Dev experiment', 'dev', now() - interval '1 minute') + `); +}); + +afterEach(() => { + if (ORIGINAL === undefined) delete process.env.TRIGGER_SECRET_KEY; + else process.env.TRIGGER_SECRET_KEY = ORIGINAL; +}); + +describe("environment scoping", () => { + it("prod lists only prod-born conversations", async () => { + process.env.TRIGGER_SECRET_KEY = "tr_prod_x"; + const page = await pageConversations(); + expect(page.items.map((row) => row.id)).toEqual(["p-1"]); + }); + + it("dev is the workbench and lists everything", async () => { + process.env.TRIGGER_SECRET_KEY = "tr_dev_x"; + const page = await pageConversations(); + expect(page.items.map((row) => row.id)).toEqual(["p-1", "d-1"]); + }); + + it("search scopes the same way", async () => { + process.env.TRIGGER_SECRET_KEY = "tr_prod_x"; + expect(await searchConversations("e")).toHaveLength(1); + process.env.TRIGGER_SECRET_KEY = "tr_dev_x"; + expect(await searchConversations("e")).toHaveLength(2); + }); +}); diff --git a/lib/db/queries.ts b/lib/db/queries.ts index 1ed117d..e43bfc9 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -1,10 +1,18 @@ import { and, desc, eq, ilike, lt, or } from "drizzle-orm"; import { cache } from "react"; +import { triggerEnv } from "../rendi/trigger-env.ts"; import { getDb } from "./index.ts"; import { conversations, messages } from "./schema.ts"; export const CONVERSATION_PAGE = 25; +// Prod lists only prod-born conversations (dev experiments would render +// as dead rows there); dev is the workbench and lists everything. +function envScope() { + const env = triggerEnv(); + return env === "prod" ? eq(conversations.triggerEnv, env) : undefined; +} + export type ConversationCursor = { updatedAt: string; id: string }; export type ConversationPage = { @@ -26,15 +34,18 @@ export async function pageConversations( }) .from(conversations) .where( - before - ? or( - lt(conversations.updatedAt, new Date(before.updatedAt)), - and( - eq(conversations.updatedAt, new Date(before.updatedAt)), - lt(conversations.id, before.id), - ), - ) - : undefined, + and( + envScope(), + before + ? or( + lt(conversations.updatedAt, new Date(before.updatedAt)), + and( + eq(conversations.updatedAt, new Date(before.updatedAt)), + lt(conversations.id, before.id), + ), + ) + : undefined, + ), ) .orderBy(desc(conversations.updatedAt), desc(conversations.id)) .limit(CONVERSATION_PAGE + 1); @@ -57,7 +68,7 @@ export async function searchConversations(query: string, limit = 15) { updatedAt: conversations.updatedAt, }) .from(conversations) - .where(ilike(conversations.title, `%${query}%`)) + .where(and(envScope(), ilike(conversations.title, `%${query}%`))) .orderBy(desc(conversations.updatedAt), desc(conversations.id)) .limit(limit); } diff --git a/lib/db/schema.ts b/lib/db/schema.ts index c717b66..efc0062 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -11,6 +11,11 @@ import { } from "drizzle-orm/pg-core"; import type { CanvasDoc } from "../rendi/canvas"; import type { OpEntry } from "../rendi/canvas-ops"; +import type { + DdCartLine, + DdCartStatus, + DdQuoteSnapshot, +} from "../rendi/doordash-cart"; import type { InstrumentSpec, Present } from "../rendi/instrument"; export const conversations = pgTable("conversations", { @@ -19,6 +24,10 @@ export const conversations = pgTable("conversations", { publicAccessToken: text("public_access_token"), lastEventId: text("last_event_id"), turns: integer("turns").notNull().default(0), + // Birth environment (from the Trigger key prefix). The 'prod' default + // is load-bearing: worker versions that predate this column leave it + // unset, and those sessions only ever run in prod. + triggerEnv: text("trigger_env").notNull().default("prod"), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), @@ -218,8 +227,93 @@ export const emails = pgTable( (table) => [index("emails_conversation_idx").on(table.conversationId)], ); +// DoorDash render snapshots: the cart's truth lives at DoorDash, these +// rows are what cards paint from and what history renders forever. +export const ddCarts = pgTable( + "dd_carts", + { + cartUuid: text("cart_uuid").primaryKey(), + conversationId: text("conversation_id") + .notNull() + .references(() => conversations.id), + storeId: text("store_id").notNull(), + storeName: text("store_name").notNull(), + storeImageUrl: text("store_image_url"), + items: jsonb("items").notNull().$type(), + quote: jsonb("quote").$type(), + fulfillment: text("fulfillment").notNull().default("delivery"), + scheduledTime: text("scheduled_time"), + tipCents: integer("tip_cents").notNull().default(0), + status: text("status").notNull().default("open").$type(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [index("dd_carts_conversation_idx").on(table.conversationId)], +); + +// One-time-code approvals: single-use, hash-bound to the exact previewed +// cart, only satisfiable by a code from the owner's inbox. The code +// itself never persists and never enters the transcript. +export const ddApprovals = pgTable( + "dd_approvals", + { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + conversationId: text("conversation_id").notNull(), + cartUuid: text("cart_uuid").notNull(), + cartHash: text("cart_hash").notNull(), + totalCents: integer("total_cents").notNull(), + tipCents: integer("tip_cents").notNull(), + codeHash: text("code_hash").notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + attempts: integer("attempts").notNull().default(0), + verifiedAt: timestamp("verified_at", { withTimezone: true }), + consumedAt: timestamp("consumed_at", { withTimezone: true }), + voidedAt: timestamp("voided_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [index("dd_approvals_cart_idx").on(table.cartUuid)], +); + +// One row per submission attempt, keyed by its approval so a re-fired +// turn reads the recorded outcome instead of charging twice. Also the +// daily-cap counter. +export const ddOrders = pgTable( + "dd_orders", + { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + approvalId: integer("approval_id") + .notNull() + .unique() + .references(() => ddApprovals.id), + conversationId: text("conversation_id").notNull(), + cartUuid: text("cart_uuid").notNull(), + orderUuid: text("order_uuid"), + storeName: text("store_name").notNull(), + totalCents: integer("total_cents").notNull(), + status: text("status").notNull().default("pending"), + errorMessage: text("error_message"), + receipt: jsonb("receipt"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [index("dd_orders_created_idx").on(table.createdAt)], +); + export type ConversationRow = typeof conversations.$inferSelect; export type MessageRow = typeof messages.$inferSelect; +export type DdCartRow = typeof ddCarts.$inferSelect; +export type DdApprovalRow = typeof ddApprovals.$inferSelect; +export type DdOrderRow = typeof ddOrders.$inferSelect; export type InstrumentRow = typeof instruments.$inferSelect; export type InstrumentOpRow = typeof instrumentOps.$inferSelect; export type CanvasRow = typeof canvases.$inferSelect; diff --git a/lib/rendi/archive.ts b/lib/rendi/archive.ts index 7ecc9e7..20d75a0 100644 --- a/lib/rendi/archive.ts +++ b/lib/rendi/archive.ts @@ -1,12 +1,10 @@ -// Conversations born before the production cutover rode the dev -// environment; a deployed instance cannot reach those sessions, so it -// shows them as read-only galleries. Locally (no gate configured) they -// stay live, because the dev environment is right there. Time plus -// deployment is the whole test: nothing born after the cutover can ever -// be archived, so new conversations are safe by construction. -const ARCHIVE_CUTOVER = Date.parse("2026-07-22T20:40:00Z"); +import { triggerEnv } from "./trigger-env.ts"; -export function isArchived(createdAt: Date): boolean { - if (!process.env.ACCESS_TOKEN_SECRET) return false; - return createdAt.getTime() < ARCHIVE_CUTOVER; +// A conversation's durable session lives in the Trigger environment it +// was born in; the other environment can render every persisted row but +// never reach the session, so cross-environment opens become read-only +// galleries, in both directions. Rows older than the stamp were +// backfilled by the production cutover. +export function isArchived(bornEnv: string): boolean { + return bornEnv !== triggerEnv(); } diff --git a/lib/rendi/doordash-approval.test.ts b/lib/rendi/doordash-approval.test.ts new file mode 100644 index 0000000..16faeb2 --- /dev/null +++ b/lib/rendi/doordash-approval.test.ts @@ -0,0 +1,273 @@ +import { eq } from "drizzle-orm"; +import type { drizzle } from "drizzle-orm/pglite"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ddApprovals, ddOrders } from "../db/schema.ts"; +import { + checkCaps, + consumeApproval, + createApproval, + hashCart, + totalWithTip, + verifyApproval, + voidApproval, +} from "./doordash-approval.ts"; +import type { DdCartLine, DdQuoteSnapshot } from "./doordash-cart.ts"; +import { createTestDb } from "./harness/test-db.ts"; + +const holder = vi.hoisted(() => ({ db: undefined as unknown })); + +vi.mock("../db/index.ts", () => ({ + getDb: () => holder.db, +})); + +type Db = ReturnType; + +beforeEach(async () => { + holder.db = await createTestDb(); +}); + +function db(): Db { + return holder.db as Db; +} + +const line = ( + lineId: string, + itemId: string, + quantity: number, + optionIds: string[] = [], +): DdCartLine => ({ + lineId, + itemId, + name: itemId, + quantity, + priceCents: 1000, + options: optionIds.map((id) => ({ id, name: id })), +}); + +async function mintApproval( + overrides: Partial[0]> = {}, +) { + return createApproval({ + conversationId: "conv-1", + cartUuid: "cart-1", + cartHash: "h".repeat(64), + totalCents: 3317, + tipCents: 440, + ...overrides, + }); +} + +async function row(id: number) { + const [found] = await db() + .select() + .from(ddApprovals) + .where(eq(ddApprovals.id, id)); + return found; +} + +describe("hashCart", () => { + it("is insensitive to line and option order", () => { + const a = hashCart({ + items: [line("b", "pad-see-ew", 2, ["x", "y"]), line("a", "tofu", 1)], + totalCents: 3317, + tipCents: 440, + fulfillment: "delivery", + }); + const b = hashCart({ + items: [line("a", "tofu", 1), line("b", "pad-see-ew", 2, ["y", "x"])], + totalCents: 3317, + tipCents: 440, + fulfillment: "delivery", + }); + expect(a).toBe(b); + }); + + it("moves when anything the owner approved moves", () => { + const base = { + items: [line("a", "tofu", 1)], + totalCents: 3317, + tipCents: 440, + fulfillment: "delivery", + }; + const hash = hashCart(base); + expect(hashCart({ ...base, tipCents: 880 })).not.toBe(hash); + expect(hashCart({ ...base, totalCents: 3318 })).not.toBe(hash); + expect(hashCart({ ...base, fulfillment: "pickup" })).not.toBe(hash); + expect(hashCart({ ...base, items: [line("a", "tofu", 2)] })).not.toBe(hash); + }); +}); + +describe("totalWithTip", () => { + it("adds tip only when the quote actually priced", () => { + const quote = { totalBeforeTipCents: 2877 } as DdQuoteSnapshot; + expect(totalWithTip(quote, 440)).toBe(3317); + expect( + totalWithTip({ totalBeforeTipCents: null } as DdQuoteSnapshot, 440), + ).toBeNull(); + }); +}); + +describe("the approval lifecycle", () => { + it("verifies the emailed code exactly once per approval", async () => { + const approval = await mintApproval(); + expect(approval.code).toMatch(/^\d{6}$/); + expect(await verifyApproval(approval.id, approval.code)).toEqual({ + ok: true, + }); + expect((await row(approval.id)).verifiedAt).not.toBeNull(); + }); + + it("binds the code to its approval, not just to its digits", async () => { + const first = await mintApproval({ cartUuid: "cart-1" }); + const second = await mintApproval({ cartUuid: "cart-2" }); + // On the one-in-a-million digit collision, flip a digit so the + // assertion stays deterministic. + const foreign = + first.code === second.code + ? (first.code[0] === "9" ? "0" : "9") + first.code.slice(1) + : first.code; + expect(await verifyApproval(second.id, foreign)).toMatchObject({ + ok: false, + }); + }); + + it("a new approval on the same cart voids the old one", async () => { + const stale = await mintApproval(); + const fresh = await mintApproval(); + expect(await verifyApproval(stale.id, stale.code)).toEqual({ + ok: false, + reason: "gone", + }); + expect(await verifyApproval(fresh.id, fresh.code)).toEqual({ ok: true }); + }); + + it("locks and voids after five wrong guesses", async () => { + const approval = await mintApproval(); + const wrong = approval.code === "000000" ? "111111" : "000000"; + for (let guess = 1; guess <= 4; guess++) { + expect(await verifyApproval(approval.id, wrong)).toEqual({ + ok: false, + reason: "wrong", + attemptsLeft: 5 - guess, + }); + } + expect(await verifyApproval(approval.id, wrong)).toEqual({ + ok: false, + reason: "locked", + attemptsLeft: 0, + }); + expect((await row(approval.id)).voidedAt).not.toBeNull(); + expect(await verifyApproval(approval.id, approval.code)).toEqual({ + ok: false, + reason: "gone", + }); + }); + + it("refuses expired codes even when correct", async () => { + const approval = await mintApproval(); + await db() + .update(ddApprovals) + .set({ expiresAt: new Date(Date.now() - 1000) }) + .where(eq(ddApprovals.id, approval.id)); + expect(await verifyApproval(approval.id, approval.code)).toEqual({ + ok: false, + reason: "expired", + }); + }); + + it("consumes only verified approvals, and only once", async () => { + const approval = await mintApproval(); + expect(await consumeApproval(approval.id)).toEqual({ + refused: "the code was never entered", + }); + await verifyApproval(approval.id, approval.code); + const first = await consumeApproval(approval.id); + expect("consumed" in first && first.consumed.id).toBe(approval.id); + const second = await consumeApproval(approval.id); + expect("alreadyConsumed" in second && second.alreadyConsumed.id).toBe( + approval.id, + ); + }); + + it("refuses to consume a verified approval that expired waiting", async () => { + const approval = await mintApproval(); + await verifyApproval(approval.id, approval.code); + await db() + .update(ddApprovals) + .set({ expiresAt: new Date(Date.now() - 1000) }) + .where(eq(ddApprovals.id, approval.id)); + expect(await consumeApproval(approval.id)).toEqual({ + refused: "the approval expired", + }); + }); + + it("void never rewinds a consumed approval", async () => { + const approval = await mintApproval(); + await verifyApproval(approval.id, approval.code); + await consumeApproval(approval.id); + await voidApproval(approval.id); + expect((await row(approval.id)).voidedAt).toBeNull(); + }); +}); + +describe("checkCaps", () => { + it("refuses a cart above the order cap", async () => { + const denied = await checkCaps(10_001, "conv-1"); + expect(denied?.denied).toContain("order cap"); + expect(await checkCaps(10_000, "conv-1")).toBeNull(); + }); + + it("closes after the daily order budget is spent", async () => { + for (let n = 0; n < 3; n++) { + const approval = await mintApproval({ cartUuid: `cart-${n}` }); + await db() + .insert(ddOrders) + .values({ + approvalId: approval.id, + conversationId: "conv-1", + cartUuid: `cart-${n}`, + storeName: "Toomie's Thai", + totalCents: 3317, + status: "successful", + }); + } + const denied = await checkCaps(3317, "conv-9"); + expect(denied?.denied).toContain("daily cap"); + }); + + it("rate-limits approval requests per conversation", async () => { + await mintApproval(); + await mintApproval(); + await mintApproval(); + const denied = await checkCaps(3317, "conv-1"); + expect(denied?.denied).toContain("too many approval requests"); + expect(await checkCaps(3317, "conv-2")).toBeNull(); + }); + + it("closes the approval inbox for the day", async () => { + for (let n = 0; n < 15; n++) { + await mintApproval({ + conversationId: `conv-${n}`, + cartUuid: `cart-${n}`, + }); + } + const denied = await checkCaps(3317, "conv-fresh"); + expect(denied?.denied).toContain("closed for today"); + }); + + it("the daily budget widens by env for a challenge window", async () => { + const original = process.env.DD_MAX_APPROVALS_PER_DAY; + process.env.DD_MAX_APPROVALS_PER_DAY = "2"; + try { + await mintApproval({ conversationId: "conv-a", cartUuid: "cart-a" }); + await mintApproval({ conversationId: "conv-b", cartUuid: "cart-b" }); + const denied = await checkCaps(3317, "conv-c"); + expect(denied?.denied).toContain("closed for today"); + process.env.DD_MAX_APPROVALS_PER_DAY = "40"; + expect(await checkCaps(3317, "conv-c")).toBeNull(); + } finally { + if (original === undefined) delete process.env.DD_MAX_APPROVALS_PER_DAY; + else process.env.DD_MAX_APPROVALS_PER_DAY = original; + } + }); +}); diff --git a/lib/rendi/doordash-approval.ts b/lib/rendi/doordash-approval.ts new file mode 100644 index 0000000..3701e0c --- /dev/null +++ b/lib/rendi/doordash-approval.ts @@ -0,0 +1,248 @@ +import { createHash, randomInt, timingSafeEqual } from "node:crypto"; +import { and, count, eq, gt, isNull, sql } from "drizzle-orm"; +import { getDb } from "../db/index.ts"; +import { type DdApprovalRow, ddApprovals, ddOrders } from "../db/schema.ts"; +import type { DdCartLine, DdQuoteSnapshot } from "./doordash-cart.ts"; + +// The approval machine. A single-use row, hash-bound to the exact +// previewed cart, satisfiable only by a code from the owner's inbox. +// The code never persists and never enters a transcript; possession of +// the inbox is the authorization. + +const CODE_TTL_MS = 15 * 60 * 1000; +const MAX_ATTEMPTS = 5; + +export function maxOrderCents(): number { + return Number(process.env.DD_MAX_ORDER_CENTS ?? 10_000); +} + +export function maxOrdersPerDay(): number { + return Number(process.env.DD_MAX_ORDERS_PER_DAY ?? 3); +} + +// Twenty strangers with gate codes must not be able to bomb the owner's +// inbox: requests are capped per conversation per hour and globally per +// day. The daily budget is env-tunable for a challenge window. +const APPROVALS_PER_CONVERSATION_HOUR = 3; + +export function maxApprovalsPerDay(): number { + return Number(process.env.DD_MAX_APPROVALS_PER_DAY ?? 15); +} + +export function hashCart(input: { + items: DdCartLine[]; + totalCents: number; + tipCents: number; + fulfillment: string; +}): string { + const normalized = { + items: [...input.items] + .sort((a, b) => a.lineId.localeCompare(b.lineId)) + .map((line) => ({ + itemId: line.itemId, + quantity: line.quantity, + options: [...line.options].map((option) => option.id).sort(), + })), + totalCents: input.totalCents, + tipCents: input.tipCents, + fulfillment: input.fulfillment, + }; + return createHash("sha256").update(JSON.stringify(normalized)).digest("hex"); +} + +export function totalWithTip( + quote: DdQuoteSnapshot, + tipCents: number, +): number | null { + return quote.totalBeforeTipCents == null + ? null + : quote.totalBeforeTipCents + tipCents; +} + +function hashCode(code: string, approvalId: number): string { + return createHash("sha256").update(`${code}:${approvalId}`).digest("hex"); +} + +export type ApprovalDenied = { denied: string }; + +export async function checkCaps( + totalCents: number, + conversationId: string, +): Promise { + if (totalCents > maxOrderCents()) { + return { + denied: `the order cap is $${(maxOrderCents() / 100).toFixed(0)} and this cart totals $${(totalCents / 100).toFixed(2)}`, + }; + } + const db = getDb(); + const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + const [orders] = await db + .select({ n: count() }) + .from(ddOrders) + .where(gt(ddOrders.createdAt, dayAgo)); + if ((orders?.n ?? 0) >= maxOrdersPerDay()) { + return { denied: `the daily cap of ${maxOrdersPerDay()} orders is spent` }; + } + const hourAgo = new Date(Date.now() - 60 * 60 * 1000); + const [recent] = await db + .select({ n: count() }) + .from(ddApprovals) + .where( + and( + eq(ddApprovals.conversationId, conversationId), + gt(ddApprovals.createdAt, hourAgo), + ), + ); + if ((recent?.n ?? 0) >= APPROVALS_PER_CONVERSATION_HOUR) { + return { + denied: "too many approval requests from this conversation; wait a while", + }; + } + const [today] = await db + .select({ n: count() }) + .from(ddApprovals) + .where(gt(ddApprovals.createdAt, dayAgo)); + if ((today?.n ?? 0) >= maxApprovalsPerDay()) { + return { denied: "the approval inbox is closed for today" }; + } + return null; +} + +// One live approval per cart: a new request voids what came before, so +// a stale emailed code can never approve a newer cart. +export async function createApproval(input: { + conversationId: string; + cartUuid: string; + cartHash: string; + totalCents: number; + tipCents: number; +}): Promise<{ id: number; code: string; expiresAt: Date }> { + const db = getDb(); + await db + .update(ddApprovals) + .set({ voidedAt: new Date() }) + .where( + and( + eq(ddApprovals.cartUuid, input.cartUuid), + isNull(ddApprovals.consumedAt), + isNull(ddApprovals.voidedAt), + ), + ); + const code = String(randomInt(0, 1_000_000)).padStart(6, "0"); + const expiresAt = new Date(Date.now() + CODE_TTL_MS); + const [row] = await db + .insert(ddApprovals) + .values({ + conversationId: input.conversationId, + cartUuid: input.cartUuid, + cartHash: input.cartHash, + totalCents: input.totalCents, + tipCents: input.tipCents, + codeHash: "pending", + expiresAt, + }) + .returning({ id: ddApprovals.id }); + await db + .update(ddApprovals) + .set({ codeHash: hashCode(code, row.id) }) + .where(eq(ddApprovals.id, row.id)); + return { id: row.id, code, expiresAt }; +} + +export type VerifyResult = + | { ok: true } + | { + ok: false; + reason: "expired" | "locked" | "wrong" | "gone"; + attemptsLeft?: number; + }; + +export async function verifyApproval( + approvalId: number, + code: string, +): Promise { + const db = getDb(); + return db.transaction(async (tx) => { + const rows = await tx + .select() + .from(ddApprovals) + .where(eq(ddApprovals.id, approvalId)) + .for("update"); + const row = rows[0]; + if (!row || row.voidedAt || row.consumedAt) + return { ok: false, reason: "gone" }; + if (row.expiresAt.getTime() < Date.now()) { + return { ok: false, reason: "expired" }; + } + if (row.attempts >= MAX_ATTEMPTS) return { ok: false, reason: "locked" }; + const expected = Buffer.from(row.codeHash, "hex"); + const provided = Buffer.from(hashCode(code.trim(), approvalId), "hex"); + const match = + expected.length === provided.length && + timingSafeEqual(expected, provided); + if (!match) { + const attempts = row.attempts + 1; + await tx + .update(ddApprovals) + .set({ + attempts, + ...(attempts >= MAX_ATTEMPTS ? { voidedAt: new Date() } : {}), + }) + .where(eq(ddApprovals.id, approvalId)); + return { + ok: false, + reason: attempts >= MAX_ATTEMPTS ? "locked" : "wrong", + attemptsLeft: Math.max(0, MAX_ATTEMPTS - attempts), + }; + } + await tx + .update(ddApprovals) + .set({ verifiedAt: new Date() }) + .where(eq(ddApprovals.id, approvalId)); + return { ok: true }; + }); +} + +export type ConsumeResult = + | { consumed: DdApprovalRow } + | { alreadyConsumed: DdApprovalRow } + | { refused: string }; + +// Single-use under lock. A re-fired turn calling submit again lands on +// alreadyConsumed and reads the recorded outcome instead of paying twice. +export async function consumeApproval( + approvalId: number, +): Promise { + const db = getDb(); + return db.transaction(async (tx) => { + const rows = await tx + .select() + .from(ddApprovals) + .where(eq(ddApprovals.id, approvalId)) + .for("update"); + const row = rows[0]; + if (!row) return { refused: "no such approval" }; + if (row.consumedAt) return { alreadyConsumed: row }; + if (row.voidedAt) return { refused: "the approval was voided" }; + if (!row.verifiedAt) return { refused: "the code was never entered" }; + if (row.expiresAt.getTime() < Date.now()) { + return { refused: "the approval expired" }; + } + await tx + .update(ddApprovals) + .set({ consumedAt: sql`now()` }) + .where(eq(ddApprovals.id, approvalId)); + const [fresh] = await tx + .select() + .from(ddApprovals) + .where(eq(ddApprovals.id, approvalId)); + return { consumed: fresh }; + }); +} + +export async function voidApproval(approvalId: number): Promise { + await getDb() + .update(ddApprovals) + .set({ voidedAt: new Date() }) + .where(and(eq(ddApprovals.id, approvalId), isNull(ddApprovals.consumedAt))); +} diff --git a/lib/rendi/doordash-cart.test.ts b/lib/rendi/doordash-cart.test.ts new file mode 100644 index 0000000..ab39a56 --- /dev/null +++ b/lib/rendi/doordash-cart.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { + cartAddRequiredOptionsFixture, + cartShowFixture, + previewFixture, +} from "./doordash.fixtures.ts"; +import { normalizeCartLines, normalizeQuote } from "./doordash-cart.ts"; +import { ddCartEnvelope, ddPreviewResult } from "./doordash-schemas.ts"; + +describe("cart wire shapes", () => { + it("parses a live cart with option names on the line", () => { + const envelope = ddCartEnvelope.parse(cartShowFixture); + const lines = normalizeCartLines(envelope.cart); + expect(lines).toHaveLength(1); + expect(lines[0]).toMatchObject({ + lineId: "line-1", + itemId: "8001", + quantity: 3, + priceCents: 1850, + options: [{ id: "44958508375", name: "Medium" }], + }); + }); + + it("surfaces required-options failures as a render-ready schema", () => { + const envelope = ddCartEnvelope.parse(cartAddRequiredOptionsFixture); + expect(envelope.success).toBe(false); + const need = envelope.item_errors[0].required_options[0]; + expect(need.name).toBe("Ice Level"); + expect(need.options.map((option) => option.name)).toEqual([ + "Regular Ice", + "Less Ice", + ]); + }); +}); + +describe("quote normalization", () => { + it("keeps the ladder in cents with display strings and discount signs", () => { + const preview = ddPreviewResult.parse(previewFixture); + const quote = normalizeQuote(preview); + expect(quote?.ladder.map((line) => line.chargeId)).toEqual([ + "SUBTOTAL", + "DELIVERY_FEE", + "TAXES_AND_FEES", + "PROMOTION_DISCOUNT", + ]); + expect(quote?.ladder[0]).toMatchObject({ cents: 4398, negative: false }); + expect(quote?.ladder[3]).toMatchObject({ cents: 1999, negative: true }); + // The struck-through original survives only when it differs. + expect(quote?.ladder[1].originalCents).toBe(99); + expect(quote?.ladder[0].originalCents).toBeUndefined(); + expect(quote?.totalBeforeTipCents).toBe(2877); + expect(quote?.etaRange).toBe("26-41 min"); + expect(quote?.deliveryOptions.map((option) => option.type)).toEqual([ + "STANDARD", + "SCHEDULE", + ]); + }); + + it("renders a closed store honestly", () => { + const closed = ddPreviewResult.parse({ + ...previewFixture, + quote: { + ...previewFixture.quote, + delivery_availability: { + ...previewFixture.quote.delivery_availability, + asap_available: false, + }, + }, + }); + expect(normalizeQuote(closed)?.asapAvailable).toBe(false); + }); +}); diff --git a/lib/rendi/doordash-cart.ts b/lib/rendi/doordash-cart.ts new file mode 100644 index 0000000..b3235f4 --- /dev/null +++ b/lib/rendi/doordash-cart.ts @@ -0,0 +1,99 @@ +import type { z } from "zod"; +import type { ddCartEnvelope, ddPreviewResult } from "./doordash-schemas.ts"; + +// The cart's truth lives at DoorDash; these are the render snapshots we +// keep in Neon so cards paint instantly and history renders forever. +// Money is integer cents everywhere; dollars exist only at render. + +export type DdCartLine = { + lineId: string; + itemId: string; + menuId?: string | null; + name: string; + quantity: number; + priceCents: number | null; + imageUrl?: string | null; + // Ids ride along so a quantity change can re-add the line with the + // exact same modifiers, which is what keeps the backend merging. + options: { id: string; name: string }[]; +}; + +export type DdQuoteSnapshot = { + ladder: { + chargeId: string; + label: string; + cents: number; + displayString: string; + negative: boolean; + originalCents?: number; + }[]; + totalBeforeTipCents: number | null; + currency: string; + asapAvailable: boolean; + etaRange?: string | null; + deliveryOptions: { type: string; title: string; etaRange?: string | null }[]; +}; + +export type DdCartStatus = + | "open" + | "awaiting_code" + | "placing" + | "placed" + | "failed" + | "voided" + | "abandoned"; + +function dollarsToCents(dollars: number | null | undefined): number | null { + return dollars == null ? null : Math.round(dollars * 100); +} + +export function normalizeCartLines( + cart: z.infer["cart"], +): DdCartLine[] { + if (!cart) return []; + return cart.items.map((line) => ({ + lineId: line.id, + itemId: line.item_id, + menuId: line.menu_id ?? null, + name: line.name, + quantity: line.quantity, + priceCents: dollarsToCents(line.price), + imageUrl: line.image_url ?? null, + options: line.nested_options.flatMap((option) => { + const name = option.item_extra_option?.name; + const optionId = option.item_extra_option?.id ?? option.id; + return name && optionId ? [{ id: optionId, name }] : []; + }), + })); +} + +export function normalizeQuote( + preview: z.infer, +): DdQuoteSnapshot | null { + const quote = preview.quote; + if (!quote) return null; + const availability = quote.delivery_availability; + return { + ladder: quote.line_items.map((line) => ({ + chargeId: line.charge_id, + label: line.label, + cents: line.final_money.unit_amount, + displayString: line.final_money.display_string, + // Their sign flag is true for charges; discounts arrive false. + negative: line.final_money.sign === false, + ...(line.original_money && + line.original_money.unit_amount !== line.final_money.unit_amount + ? { originalCents: line.original_money.unit_amount } + : {}), + })), + totalBeforeTipCents: quote.total_before_tip?.unit_amount ?? null, + currency: quote.currency ?? "USD", + asapAvailable: availability?.asap_available ?? false, + etaRange: availability?.asap_minutes_range_string ?? null, + deliveryOptions: (availability?.delivery_options ?? []).map((option) => ({ + type: option.delivery_option_type, + title: option.option_title, + etaRange: option.eta_minutes_range ?? null, + })), + }; +} diff --git a/lib/rendi/doordash-db.ts b/lib/rendi/doordash-db.ts new file mode 100644 index 0000000..86481ad --- /dev/null +++ b/lib/rendi/doordash-db.ts @@ -0,0 +1,79 @@ +import { eq } from "drizzle-orm"; +import { getDb } from "../db/index.ts"; +import { type DdCartRow, ddCarts } from "../db/schema.ts"; +import type { + DdCartLine, + DdCartStatus, + DdQuoteSnapshot, +} from "./doordash-cart.ts"; + +// The single writer for cart render snapshots. DoorDash stays the source +// of truth; these rows are what cards paint from, instantly and forever. + +export async function upsertCartSnapshot(snapshot: { + cartUuid: string; + conversationId: string; + storeId: string; + storeName: string; + storeImageUrl?: string | null; + items: DdCartLine[]; + quote?: DdQuoteSnapshot | null; + fulfillment?: string; + tipCents?: number; + status?: DdCartStatus; +}): Promise { + const patch = { + items: snapshot.items, + ...(snapshot.quote !== undefined ? { quote: snapshot.quote } : {}), + ...(snapshot.fulfillment ? { fulfillment: snapshot.fulfillment } : {}), + ...(snapshot.tipCents !== undefined ? { tipCents: snapshot.tipCents } : {}), + ...(snapshot.status ? { status: snapshot.status } : {}), + updatedAt: new Date(), + }; + await getDb() + .insert(ddCarts) + .values({ + cartUuid: snapshot.cartUuid, + conversationId: snapshot.conversationId, + storeId: snapshot.storeId, + storeName: snapshot.storeName, + storeImageUrl: snapshot.storeImageUrl ?? null, + items: snapshot.items, + quote: snapshot.quote ?? null, + fulfillment: snapshot.fulfillment ?? "delivery", + tipCents: snapshot.tipCents ?? 0, + status: snapshot.status ?? "open", + }) + .onConflictDoUpdate({ target: ddCarts.cartUuid, set: patch }); +} + +export async function getCartSnapshot( + cartUuid: string, +): Promise { + const rows = await getDb() + .select() + .from(ddCarts) + .where(eq(ddCarts.cartUuid, cartUuid)) + .limit(1); + return rows[0]; +} + +export async function setCartStatus( + cartUuid: string, + status: DdCartStatus, +): Promise { + await getDb() + .update(ddCarts) + .set({ status, updatedAt: new Date() }) + .where(eq(ddCarts.cartUuid, cartUuid)); +} + +export async function setCartTip( + cartUuid: string, + tipCents: number, +): Promise { + await getDb() + .update(ddCarts) + .set({ tipCents, updatedAt: new Date() }) + .where(eq(ddCarts.cartUuid, cartUuid)); +} diff --git a/lib/rendi/doordash-email.ts b/lib/rendi/doordash-email.ts new file mode 100644 index 0000000..51bbbcc --- /dev/null +++ b/lib/rendi/doordash-email.ts @@ -0,0 +1,80 @@ +import { Resend } from "resend"; + +// The approval code email, in the house dress. The code is the whole +// message: possession of this inbox is the authorization, so nothing in +// the email is clickable and the decision facts ride above the code. + +let client: Resend | undefined; + +function resend(): Resend { + if (!client) { + const key = process.env.RESEND_API_KEY; + if (!key) throw new Error("RESEND_API_KEY is not set"); + client = new Resend(key); + } + return client; +} + +export function ownerEmail(): string { + const to = process.env.RENDI_OWNER_EMAIL; + if (!to) throw new Error("RENDI_OWNER_EMAIL is not set"); + return to; +} + +export function approvalEmailHtml(input: { + code: string; + storeName: string; + itemsCount: number; + totalDisplay: string; + destination: string; + conversationTitle: string; + minutes: number; +}): string { + const facts = [ + ["Store", input.storeName], + ["Items", String(input.itemsCount)], + ["Total with tip", input.totalDisplay], + ["Deliver to", input.destination], + ["Asked from", input.conversationTitle], + ] + .map( + ([label, value]) => + `${label}${value}`, + ) + .join(""); + return `
+
+

RENDI ORDER

+
+

An order is waiting for your yes.

+ ${facts}
+

${input.code}

+

Type this code into the order card. It works once and dies in ${input.minutes} minutes; any change to the cart voids it. Not you? Ignore this and nothing happens.

+
+

rendi.help

+
+
`; +} + +export async function sendApprovalEmail(input: { + code: string; + storeName: string; + itemsCount: number; + totalDisplay: string; + destination: string; + conversationTitle: string; + minutes: number; + approvalId: number; +}): Promise { + const { error } = await resend().emails.send( + { + from: "Rendi ", + replyTo: "Rendi ", + to: ownerEmail(), + subject: `Approve ${input.totalDisplay} at ${input.storeName}`, + html: approvalEmailHtml(input), + }, + { idempotencyKey: `dd-approval/${input.approvalId}` }, + ); + if (error) throw new Error(`approval email failed: ${error.message}`); +} diff --git a/lib/rendi/doordash-ops.ts b/lib/rendi/doordash-ops.ts new file mode 100644 index 0000000..c299b91 --- /dev/null +++ b/lib/rendi/doordash-ops.ts @@ -0,0 +1,269 @@ +import type { z } from "zod"; +import * as dd from "./doordash.ts"; +import { + type DdCartLine, + type DdQuoteSnapshot, + normalizeCartLines, + normalizeQuote, +} from "./doordash-cart.ts"; +import { + getCartSnapshot, + setCartStatus, + setCartTip, + upsertCartSnapshot, +} from "./doordash-db.ts"; +import type { ddCartEnvelope } from "./doordash-schemas.ts"; + +// One implementation of every cart operation, shared by the agent's tool +// and the UI's exec task, so both hands move the cart through identical +// physics: mutate at DoorDash, re-preview for honest money (law 10), +// upsert the snapshot the cards paint from. + +export type CartOpResult = { + cartUuid: string; + storeId: string; + storeName: string; + items: DdCartLine[]; + quote: DdQuoteSnapshot | null; + tipCents: number; + fulfillment: string; + message?: string | null; + itemErrors?: z.infer["item_errors"]; +}; + +function lineToNewItem(line: DdCartLine, quantity: number): dd.DdNewCartItem { + return { + item_id: line.itemId, + item_name: line.name, + quantity, + // Same modifiers, so the backend merges into the existing line + // instead of forking a second one. + ...(line.options.length + ? { + nested_options: line.options.map((option) => ({ + id: option.id, + name: option.name, + quantity: 1, + })), + } + : {}), + }; +} + +async function refreshedSnapshot(input: { + cartUuid: string; + conversationId: string; + goal: string; + storeId: string; + storeName: string; + storeImageUrl?: string | null; + envelope?: z.infer; + tipCents?: number; + fulfillment?: string; +}): Promise { + const shown = + input.envelope?.cart && input.envelope.cart.items.length > 0 + ? input.envelope + : await dd.cartShow(input.cartUuid, input.goal); + const items = normalizeCartLines(shown.cart ?? null); + const preview = + items.length > 0 + ? await dd.orderPreview( + { + cartUuid: input.cartUuid, + ...(input.fulfillment + ? { fulfillment: input.fulfillment as "delivery" | "pickup" } + : {}), + }, + input.goal, + ) + : null; + const quote = preview ? normalizeQuote(preview) : null; + const existing = await getCartSnapshot(input.cartUuid); + const subtotal = quote?.ladder.find( + (line) => line.chargeId === "SUBTOTAL", + )?.cents; + // The owner tips 10 percent by default; a fresh cart is born with it + // so the total is never quietly tipless. + const tipCents = + input.tipCents ?? + existing?.tipCents ?? + (subtotal ? Math.round(subtotal * 0.1) : 0); + const fulfillment = input.fulfillment ?? existing?.fulfillment ?? "delivery"; + await upsertCartSnapshot({ + cartUuid: input.cartUuid, + conversationId: input.conversationId, + storeId: input.storeId, + storeName: input.storeName, + storeImageUrl: input.storeImageUrl, + items, + quote, + tipCents, + fulfillment, + status: "open", + }); + return { + cartUuid: input.cartUuid, + storeId: input.storeId, + storeName: input.storeName, + items, + quote, + tipCents, + fulfillment, + message: input.envelope?.message, + itemErrors: input.envelope?.item_errors, + }; +} + +export async function opAddItems(input: { + conversationId: string; + goal: string; + storeId: string; + storeName: string; + storeImageUrl?: string | null; + menuId: string; + items: dd.DdNewCartItem[]; + cartUuid?: string; + fulfillment?: "delivery" | "pickup"; +}): Promise { + const envelope = await dd.cartAddItems( + { + storeId: input.storeId, + menuId: input.menuId, + items: input.items, + cartUuid: input.cartUuid, + fulfillment: input.fulfillment, + }, + input.goal, + ); + if (envelope.success === false && envelope.item_errors.length > 0) { + return { needsChoices: true, itemErrors: envelope.item_errors }; + } + const cartUuid = envelope.cart_uuid ?? input.cartUuid; + if (!cartUuid) throw new Error("cart add-items returned no cart"); + return refreshedSnapshot({ + cartUuid, + conversationId: input.conversationId, + goal: input.goal, + storeId: input.storeId, + storeName: envelope.cart?.store_name ?? input.storeName, + storeImageUrl: input.storeImageUrl, + envelope, + }); +} + +export async function opSetQuantity(input: { + conversationId: string; + goal: string; + cartUuid: string; + lineId: string; + quantity: number; +}): Promise { + const snapshot = await getCartSnapshot(input.cartUuid); + if (!snapshot) throw new Error("unknown cart"); + const shown = await dd.cartShow(input.cartUuid, input.goal); + const lines = normalizeCartLines(shown.cart ?? null); + const line = lines.find((candidate) => candidate.lineId === input.lineId); + if (!line) throw new Error("that line is no longer in the cart"); + const target = Math.max(0, Math.round(input.quantity)); + const delta = target - line.quantity; + if (target === 0 || delta < 0) { + // The backend has no target-quantity; shrinking means remove, then + // re-add at the target with the same modifiers. + await dd.cartRemoveItem( + { cartUuid: input.cartUuid, cartItemId: line.lineId }, + input.goal, + ); + if (target > 0 && line.menuId) { + await dd.cartAddItems( + { + storeId: snapshot.storeId, + menuId: line.menuId, + items: [lineToNewItem(line, target)], + cartUuid: input.cartUuid, + }, + input.goal, + ); + } + } else if (delta > 0) { + if (!line.menuId) throw new Error("line carries no menu id to add against"); + await dd.cartAddItems( + { + storeId: snapshot.storeId, + menuId: line.menuId, + items: [lineToNewItem(line, delta)], + cartUuid: input.cartUuid, + }, + input.goal, + ); + } + return refreshedSnapshot({ + cartUuid: input.cartUuid, + conversationId: input.conversationId, + goal: input.goal, + storeId: snapshot.storeId, + storeName: snapshot.storeName, + storeImageUrl: snapshot.storeImageUrl, + }); +} + +export async function opRemoveLine(input: { + conversationId: string; + goal: string; + cartUuid: string; + lineId: string; +}): Promise { + const snapshot = await getCartSnapshot(input.cartUuid); + if (!snapshot) throw new Error("unknown cart"); + const envelope = await dd.cartRemoveItem( + { cartUuid: input.cartUuid, cartItemId: input.lineId }, + input.goal, + ); + return refreshedSnapshot({ + cartUuid: input.cartUuid, + conversationId: input.conversationId, + goal: input.goal, + storeId: snapshot.storeId, + storeName: snapshot.storeName, + storeImageUrl: snapshot.storeImageUrl, + envelope, + }); +} + +export async function opPreview(input: { + conversationId: string; + goal: string; + cartUuid: string; + fulfillment?: "delivery" | "pickup"; +}): Promise { + const snapshot = await getCartSnapshot(input.cartUuid); + if (!snapshot) throw new Error("unknown cart"); + return refreshedSnapshot({ + cartUuid: input.cartUuid, + conversationId: input.conversationId, + goal: input.goal, + storeId: snapshot.storeId, + storeName: snapshot.storeName, + storeImageUrl: snapshot.storeImageUrl, + fulfillment: input.fulfillment, + }); +} + +export async function opSetTip(input: { + cartUuid: string; + tipCents: number; +}): Promise<{ cartUuid: string; tipCents: number }> { + // Tip is a submit-time argument at DoorDash; until then it is ours. + const tipCents = Math.max(0, Math.round(input.tipCents)); + await setCartTip(input.cartUuid, tipCents); + return { cartUuid: input.cartUuid, tipCents }; +} + +export async function opDeleteCart(input: { + goal: string; + cartUuid: string; +}): Promise<{ deleted: true }> { + await dd.cartDelete(input.cartUuid, input.goal); + await setCartStatus(input.cartUuid, "abandoned"); + return { deleted: true }; +} diff --git a/lib/rendi/doordash-schemas.ts b/lib/rendi/doordash-schemas.ts new file mode 100644 index 0000000..60efb37 --- /dev/null +++ b/lib/rendi/doordash-schemas.ts @@ -0,0 +1,321 @@ +import { z } from "zod"; + +// Shapes for the slice of each dd-cli envelope we actually surface, +// grounded in live captures (2026-08-10, CLI v0.2.2). Zod objects strip +// unknown keys, which is deliberate twice over: the model's context stays +// lean, and DoorDash's popularity fields (is_popular, popularity_rank) +// never reach it, per the CLI's own embargo on using them. + +const id = z.coerce.string(); + +export const ddStore = z.object({ + store_id: id, + name: z.string(), + verified_name: z.string().nullish(), + rating: z.number().nullish(), + review_count: z.coerce.number().nullish(), + image_url: z.string().nullish(), + delivery_time: z.string().nullish(), + distance_meters: z.number().nullish(), + menu_id: id.nullish(), + printable_address: z.string().nullish(), + availability_status: z.string().nullish(), +}); +export type DdStore = z.infer; + +export const ddSearchResult = z.object({ + success: z.boolean().optional(), + message: z.string().nullish(), + stores: z.array(ddStore).default([]), + needs_address: z.boolean().optional(), + delivery_address: z.string().nullish(), +}); + +export const ddMenuItem = z.object({ + item_id: id, + name: z.string(), + description: z.string().nullish(), + image_url: z.string().nullish(), + price: z.number().nullish(), + price_varies: z.boolean().optional(), + is_orderable: z.boolean().optional(), + category_name: z.string().nullish(), + has_required_modifiers: z.boolean().optional(), +}); +export type DdMenuItem = z.infer; + +export const ddMenuResult = z.object({ + success: z.boolean().optional(), + message: z.string().nullish(), + menu_id: id, + store_id: id.nullish(), + store_name: z.string().nullish(), + store_is_open: z.boolean().optional(), + items: z.array(ddMenuItem).default([]), +}); + +export type DdExtraOption = { + option_id: string; + name: string; + price?: number | null; + extras?: DdExtra[]; +}; +export type DdExtra = { + extra_id: string; + title: string; + min_num_options?: number | null; + max_num_options?: number | null; + num_free_options?: number | null; + options?: DdExtraOption[]; +}; + +export const ddExtra: z.ZodType = z.lazy(() => + z.object({ + extra_id: z.string(), + title: z.string(), + min_num_options: z.number().nullish(), + max_num_options: z.number().nullish(), + num_free_options: z.number().nullish(), + options: z.array(ddExtraOption).optional(), + }), +); +export const ddExtraOption: z.ZodType = z.lazy(() => + z.object({ + option_id: z.string(), + name: z.string(), + price: z.number().nullish(), + extras: z.array(ddExtra).optional(), + }), +); + +export const ddItemDetails = z.object({ + success: z.boolean().optional(), + message: z.string().nullish(), + item: z.object({ + item_id: id, + name: z.string(), + description: z.string().nullish(), + image_url: z.string().nullish(), + price: z.number().nullish(), + price_varies: z.boolean().optional(), + is_orderable: z.boolean().optional(), + has_required_modifiers: z.boolean().optional(), + extras: z.array(ddExtra).default([]), + popular_modifications: z + .array(z.object({ description: z.string() })) + .default([]), + }), +}); + +export const ddStoreDetails = z.object({ + success: z.boolean().optional(), + message: z.string().nullish(), + store: ddStore, +}); + +export const ddOrder = z.object({ + order_uuid: z.string(), + store_id: id.nullish(), + store_name: z.string().nullish(), + store_image_url: z.string().nullish(), + order_date: z.string().nullish(), + is_reorderable: z.boolean().optional(), + fulfillment_type: z.string().nullish(), + order_target: z.string().nullish(), + items: z.array(z.unknown()).default([]), +}); + +export const ddOrderHistory = z.object({ + success: z.boolean().optional(), + message: z.string().nullish(), + orders: z.array(ddOrder).default([]), + page_full: z.boolean().optional(), +}); + +export const ddAddress = z.object({ + address_id: id, + label: z.string().nullish(), + printable_address: z.string(), + street_address: z.string().nullish(), + city: z.string().nullish(), + state: z.string().nullish(), + zip_code: z.string().nullish(), + lat: z.number(), + lng: z.number(), + is_default: z.boolean().optional(), + delivery_instructions: z.string().nullish(), +}); + +export const ddAddressList = z.object({ + success: z.boolean().optional(), + message: z.string().nullish(), + addresses: z.array(ddAddress).default([]), +}); + +export const ddPaymentMethods = z.object({ + success: z.boolean().optional(), + message: z.string().nullish(), + cards: z + .array( + z.object({ + payment_method_id: id, + brand: z.string().nullish(), + last4: z.string().nullish(), + exp_month: z.coerce.number().nullish(), + exp_year: z.coerce.number().nullish(), + }), + ) + .default([]), + default_payment_method_id: id.nullish(), +}); + +// Cart wire shapes, from live lifecycle captures (add, show, remove, +// delete, list, preview at open and closed stores). + +export const ddCartLine = z.object({ + id: z.string(), + item_id: id, + menu_id: id.nullish(), + name: z.string(), + description: z.string().nullish(), + image_url: z.string().nullish(), + quantity: z.number(), + price: z.number().nullish(), + nested_options: z + .array( + z.object({ + id: id.nullish(), + quantity: z.number().nullish(), + item_extra_option: z + .object({ id: id.nullish(), name: z.string().nullish() }) + .nullish(), + }), + ) + .default([]), +}); + +export const ddCart = z.object({ + id: id.nullish(), + store_id: id.nullish(), + store_name: z.string().nullish(), + items: z.array(ddCartLine).default([]), + items_count: z.number().nullish(), + is_group_cart: z.boolean().optional(), + group_cart_url: z.string().nullish(), +}); + +export const ddItemError = z.object({ + item_id: id.nullish(), + item_name: z.string().nullish(), + error_message: z.string().nullish(), + required_options: z + .array( + z.object({ + name: z.string().nullish(), + min_num_options: z.number().nullish(), + max_num_options: z.number().nullish(), + options: z + .array(z.object({ id: id.nullish(), name: z.string().nullish() })) + .default([]), + }), + ) + .default([]), +}); + +export const ddCartEnvelope = z.object({ + success: z.boolean().optional(), + message: z.string().nullish(), + cart_uuid: id.nullish(), + cart: ddCart.nullish(), + item_errors: z.array(ddItemError).default([]), +}); + +export const ddCartList = z.object({ + success: z.boolean().optional(), + message: z.string().nullish(), + carts: z + .array( + z.object({ + cart_uuid: id, + store_id: id.nullish(), + store_name: z.string().nullish(), + items_count: z.number().nullish(), + items: z.array(z.unknown()).nullish(), + created_at: z.string().nullish(), + updated_at: z.string().nullish(), + }), + ) + .default([]), +}); + +const ddMoney = z.object({ + unit_amount: z.number(), + currency: z.string().nullish(), + display_string: z.string(), + sign: z.boolean().optional(), +}); + +export const ddPreviewResult = z.object({ + success: z.boolean().optional(), + message: z.string().nullish(), + cart_uuid: id.nullish(), + quote: z + .object({ + currency: z.string().nullish(), + line_items: z + .array( + z.object({ + charge_id: z.string(), + label: z.string(), + final_money: ddMoney, + original_money: ddMoney.nullish(), + }), + ) + .default([]), + total_before_tip: ddMoney.nullish(), + net_total_before_tip: ddMoney.nullish(), + is_pre_tippable: z.boolean().optional(), + delivery_availability: z + .object({ + asap_available: z.boolean().optional(), + asap_pickup_available: z.boolean().optional(), + asap_minutes_range_string: z.string().nullish(), + timezone: z.string().nullish(), + delivery_options: z + .array( + z.object({ + delivery_option_type: z.string(), + option_title: z.string(), + eta_minutes_range: z.string().nullish(), + }), + ) + .default([]), + }) + .nullish(), + }) + .nullish(), +}); + +// Submit and status shapes come from the CLI's documented contract; a +// real submit cannot be captured without charging, so the ceremony is +// where these earn their live proof. +export const ddSubmitResult = z.object({ + success: z.boolean().optional(), + message: z.string().nullish(), + order_uuid: z.string().nullish(), + cart_uuid: id.nullish(), +}); + +export const ddOrderStatus = z.object({ + success: z.boolean().optional(), + message: z.string().nullish(), + status: z + .enum(["successful", "pending", "action_required", "failed", "not_found"]) + .catch("pending"), + error_message: z.string().nullish(), + order_uuid: z.string().nullish(), +}); + +// Receipt and in-store item search are surfaced leniently until their +// verticals get first-class treatment: the model reads what came back. +export const ddLenient = z.record(z.string(), z.unknown()); diff --git a/lib/rendi/doordash.fixtures.ts b/lib/rendi/doordash.fixtures.ts new file mode 100644 index 0000000..f6e6b4c --- /dev/null +++ b/lib/rendi/doordash.fixtures.ts @@ -0,0 +1,344 @@ +// Sanitized copies of live dd-cli v0.2.2 envelopes (captured 2026-08-10): +// real structure, invented identifiers, fictional addresses and cards. +// These are the parser tests' ground truth; refresh by re-capturing when +// the CLI version bumps. + +export const searchFixture = { + success: true, + message: "Found 2 stores", + needs_address: false, + delivery_address: "123 Ember Lane, Oakland, CA 94601", + widget_type: "store_list", + assistant_instructions: "Render the widget above.", + stores: [ + { + store_id: "55382", + name: "Itani Ramen", + verified_name: "ITANI Ramen", + rating: 4.7, + review_count: "2400", + image_url: "https://img.example/itani.jpg", + delivery_time: "23 min", + distance: "1.1 mi", + distance_meters: 1770.3, + is_link_out: false, + zesty_store_id: "z-1", + }, + { + store_id: "33011", + name: "Sobo Ramen", + rating: 4.5, + review_count: 1800, + delivery_time: "23 min", + distance_meters: 2100.5, + }, + ], +}; + +export const menuFixture = { + success: true, + message: "", + menu_id: 1657275, + store_id: "55382", + store_name: "Itani Ramen", + store_is_open: true, + widget_type: "menu", + items: [ + { + item_id: "i_44780001", + name: "Tonkotsu Ramen", + description: "Pork bone broth, chashu, egg", + image_url: "https://img.example/tonkotsu.jpg", + price: 16.5, + price_varies: false, + extras: [], + is_orderable: true, + unavailability_reason: "", + popular_modifications: [], + is_popular: true, + popularity_rank: 1, + category_id: "c_1", + category_name: "Ramen", + has_modifiers: true, + has_required_modifiers: false, + }, + { + item_id: "i_44780002", + name: "Yuzu Shio Ramen", + price: 17.25, + is_orderable: true, + is_popular: false, + popularity_rank: 9, + category_name: "Ramen", + }, + ], + categories: [], +}; + +export const itemDetailsFixture = { + success: true, + message: "", + item: { + item_id: "44780001", + name: "Tonkotsu Ramen", + description: "Pork bone broth, chashu, egg", + image_url: "https://img.example/tonkotsu.jpg", + price: 16.5, + price_varies: false, + is_orderable: true, + has_modifiers: true, + has_required_modifiers: false, + is_popular: true, + popularity_rank: 1, + popular_modifications: [ + { description: "Add Extra Egg • Add Chashu Pork Belly", extras: [] }, + ], + extras: [ + { + extra_id: "e_9794927160", + title: "Toppings", + min_num_options: 0, + max_num_options: 5, + num_free_options: 0, + options: [ + { option_id: "o_101", name: "Add Extra Egg", price: 3, extras: [] }, + { + option_id: "o_102", + name: "Combo Upgrade", + price: 6.5, + extras: [ + { + extra_id: "e_inner", + title: "Side Choice", + options: [ + { option_id: "o_201", name: "Gyoza", price: 0 }, + { option_id: "o_202", name: "Karaage", price: 1.5 }, + ], + }, + ], + }, + ], + }, + ], + }, +}; + +export const orderHistoryFixture = { + success: true, + message: "", + page_full: false, + orders: [ + { + order_uuid: "11111111-2222-3333-4444-555555555555", + store_id: 55382, + store_name: "Itani Ramen", + store_image_url: "https://img.example/itani.jpg", + order_date: "2026-08-01T01:10:00Z", + order_fulfilled_at: "2026-08-01T01:52:00Z", + is_reorderable: true, + fulfillment_type: "FULFILLMENT_TYPE_DX_DELIVERY", + order_target: "ORDER_TARGET_RESTAURANT", + business_id: 9, + business_vertical_id: 1, + items: [{ name: "Tonkotsu Ramen", quantity: 1 }], + }, + { + order_uuid: "66666666-7777-8888-9999-000000000000", + store_name: "Closed Kitchen", + is_reorderable: false, + items: [], + }, + ], +}; + +export const addressListFixture = { + success: true, + message: "", + widget_type: "address_list", + trace_id: "t-1", + timestamp: "2026-08-10T20:00:00Z", + addresses: [ + { + address_id: "a-1", + address_link_id: "al-1", + label: "Home", + printable_address: "123 Ember Lane, Oakland, CA 94601", + street_address: "123 Ember Lane", + city: "Oakland", + state: "CA", + zip_code: "94601", + lat: 37.79, + lng: -122.27, + is_default: true, + delivery_instructions: "Gate code 000", + }, + { + address_id: "a-2", + label: "Work", + printable_address: "456 Amber Ave, Oakland, CA 94612", + lat: 37.8, + lng: -122.26, + is_default: false, + }, + ], +}; + +export const cartShowFixture = { + success: true, + cart_uuid: "cccccccc-1111-2222-3333-444444444444", + widget_type: "cart", + assistant_instructions: "Render the widget above.", + cart: { + id: "cccccccc-1111-2222-3333-444444444444", + store_id: "24006", + store_name: "Toomie's Thai", + items: [ + { + id: "line-1", + item_id: "8001", + menu_id: "9001", + name: "Pad See Ew", + description: "Flat rice noodles, egg, broccoli", + image_url: "https://img.example/padseeew.jpg", + quantity: 3, + price: 18.5, + nested_options: [ + { + id: "44958508375", + quantity: 1, + options: [], + item_extra_option: { id: "44958508375", name: "Medium" }, + }, + ], + }, + ], + items_count: 1, + group_cart_url: null, + spend_limit_cents: null, + is_group_cart: false, + subcarts: [], + }, +}; + +export const previewFixture = { + success: true, + message: "Order preview generated successfully", + cart_uuid: "cccccccc-1111-2222-3333-444444444444", + quote: { + currency: "USD", + line_items: [ + { + charge_id: "SUBTOTAL", + label: "Subtotal", + final_money: { + unit_amount: 4398, + currency: "USD", + display_string: "$43.98", + sign: true, + }, + }, + { + charge_id: "DELIVERY_FEE", + label: "Delivery Fee", + final_money: { + unit_amount: 0, + currency: "USD", + display_string: "$0.00", + sign: true, + }, + original_money: { + unit_amount: 99, + currency: "USD", + display_string: "$0.99", + sign: true, + }, + }, + { + charge_id: "TAXES_AND_FEES", + label: "Fees & Estimated Tax", + final_money: { + unit_amount: 478, + currency: "USD", + display_string: "$4.78", + sign: true, + }, + }, + { + charge_id: "PROMOTION_DISCOUNT", + label: "Discount", + final_money: { + unit_amount: 1999, + currency: "USD", + display_string: "-$19.99", + sign: false, + }, + }, + ], + total_before_tip: { + unit_amount: 2877, + currency: "USD", + display_string: "$28.77", + sign: true, + }, + is_pre_tippable: true, + delivery_availability: { + asap_available: true, + asap_pickup_available: true, + asap_minutes_range_string: "26-41 min", + timezone: "US/Pacific", + delivery_options: [ + { + delivery_option_type: "STANDARD", + option_title: "Standard", + eta_minutes_range: "26-41 min", + }, + { + delivery_option_type: "SCHEDULE", + option_title: "Schedule", + eta_minutes_range: null, + }, + ], + }, + }, +}; + +export const cartAddRequiredOptionsFixture = { + success: false, + message: "Some items need choices", + cart_uuid: null, + cart: null, + item_errors: [ + { + item_id: "8002", + item_name: "Boba Tea", + error_message: "Required options missing", + required_options: [ + { + name: "Ice Level", + min_num_options: 1, + max_num_options: 1, + options: [ + { id: "o-ice-1", name: "Regular Ice" }, + { id: "o-ice-2", name: "Less Ice" }, + ], + }, + ], + }, + ], +}; + +export const paymentMethodsFixture = { + success: true, + message: "", + cards: [ + { + payment_method_id: "pm-1", + provider_payment_method_id: "prov-1", + brand: "Visa", + last4: "4242", + exp_month: 4, + exp_year: 2030, + }, + ], + default_payment_method_id: "pm-1", +}; diff --git a/lib/rendi/doordash.test.ts b/lib/rendi/doordash.test.ts new file mode 100644 index 0000000..2f5f739 --- /dev/null +++ b/lib/rendi/doordash.test.ts @@ -0,0 +1,163 @@ +import { promisify } from "node:util"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + addressListFixture, + itemDetailsFixture, + menuFixture, + orderHistoryFixture, + paymentMethodsFixture, + searchFixture, +} from "./doordash.fixtures.ts"; +import { intentFor, runDd, search } from "./doordash.ts"; +import { + ddAddressList, + ddItemDetails, + ddMenuResult, + ddOrderHistory, + ddPaymentMethods, + ddSearchResult, +} from "./doordash-schemas.ts"; + +const impl = vi.hoisted(() => vi.fn()); + +vi.mock("node:child_process", async () => { + const { promisify: custom } = await import("node:util"); + const execFile = Object.assign(vi.fn(), { [custom.custom]: impl }); + return { execFile }; +}); + +beforeEach(() => { + impl.mockClear(); +}); + +function envelope(structuredContent: unknown) { + return { + stdout: JSON.stringify({ + content: [{ type: "text", text: "ok" }], + structuredContent, + isError: false, + }), + stderr: "", + }; +} + +describe("schemas against captured envelopes", () => { + it("parses search and strips widget noise", () => { + const result = ddSearchResult.parse(searchFixture); + expect(result.stores).toHaveLength(2); + expect(result.stores[0].store_id).toBe("55382"); + expect(result.stores[1].review_count).toBe(1800); + expect("widget_type" in result).toBe(false); + }); + + it("parses the menu and never surfaces popularity", () => { + const result = ddMenuResult.parse(menuFixture); + expect(result.menu_id).toBe("1657275"); + expect(result.items).toHaveLength(2); + for (const item of result.items) { + expect("is_popular" in item).toBe(false); + expect("popularity_rank" in item).toBe(false); + } + }); + + it("parses item details through nested combo extras", () => { + const result = ddItemDetails.parse(itemDetailsFixture); + const combo = result.item.extras[0].options?.[1]; + expect(combo?.extras?.[0].options?.map((option) => option.name)).toEqual([ + "Gyoza", + "Karaage", + ]); + expect(result.item.popular_modifications[0].description).toContain( + "Extra Egg", + ); + }); + + it("parses history, addresses, and cards", () => { + expect(ddOrderHistory.parse(orderHistoryFixture).orders[0].store_id).toBe( + "55382", + ); + const addresses = ddAddressList.parse(addressListFixture); + expect(addresses.addresses.find((address) => address.is_default)?.lat).toBe( + 37.79, + ); + expect(ddPaymentMethods.parse(paymentMethodsFixture).cards[0].last4).toBe( + "4242", + ); + }); +}); + +describe("intentFor", () => { + it("writes their two-line contract from the verbatim ask", () => { + const intent = intentFor("order me\nramen"); + expect(intent.split("\n")).toHaveLength(2); + expect(intent).toContain('user prompt/purpose: "order me ramen"'); + }); + + it("caps runaway goals", () => { + expect(intentFor("x".repeat(500)).length).toBeLessThan(300); + }); +}); + +describe("runDd", () => { + it("composes json-output plus intent and returns structuredContent", async () => { + impl.mockResolvedValueOnce(envelope({ ok: 1 })); + const result = await runDd(["address", "list"], "test goal"); + expect(result).toEqual({ ok: 1 }); + const [, args] = impl.mock.calls[0] as [string, string[]]; + expect(args[0]).toBe("--json-output"); + expect(args.slice(1, 3)).toEqual(["address", "list"]); + expect(args[3]).toBe("--intent"); + expect(args[4]).toContain("Summary:"); + }); + + it("throws the envelope's own error text on isError", async () => { + impl.mockResolvedValueOnce({ + stdout: JSON.stringify({ + content: [{ type: "text", text: "Store is closed" }], + isError: true, + }), + stderr: "", + }); + await expect(runDd(["menu"], "g")).rejects.toThrow("Store is closed"); + }); + + it("translates missing credentials into the re-mint instruction", async () => { + impl.mockRejectedValueOnce({ + code: 1, + stderr: "Error: Failed to execute command due to missing credentials.", + }); + await expect(runDd(["search"], "g")).rejects.toThrow(/export-token/); + }); + + it("names the timeout honestly", async () => { + impl.mockRejectedValueOnce({ killed: true }); + await expect(runDd(["menu"], "g")).rejects.toThrow(/timed out/); + }); +}); + +describe("search location doctrine", () => { + it("uses explicit coordinates without touching the address book", async () => { + impl.mockResolvedValueOnce(envelope(searchFixture)); + await search({ query: "ramen", lat: 1.5, lng: -2.5 }, "g"); + expect(impl).toHaveBeenCalledTimes(1); + const [, args] = impl.mock.calls[0] as [string, string[]]; + expect(args).toContain("--lat"); + expect(args[args.indexOf("--lat") + 1]).toBe("1.5"); + }); + + it("resolves the saved default address when coordinates are absent", async () => { + vi.resetModules(); + impl.mockReset(); + impl.mockResolvedValueOnce(envelope(addressListFixture)); + impl.mockResolvedValueOnce(envelope(searchFixture)); + const fresh = await import("./doordash.ts"); + await fresh.search({ query: "ramen" }, "g"); + expect(impl).toHaveBeenCalledTimes(2); + const [, searchArgs] = impl.mock.calls[1] as [string, string[]]; + expect(searchArgs[searchArgs.indexOf("--lat") + 1]).toBe("37.79"); + }); +}); + +// promisify is imported so the custom-symbol mock stays anchored to the +// same node:util instance the wrapper uses. +void promisify; diff --git a/lib/rendi/doordash.ts b/lib/rendi/doordash.ts new file mode 100644 index 0000000..38260ff --- /dev/null +++ b/lib/rendi/doordash.ts @@ -0,0 +1,388 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import type { z } from "zod"; +import { + ddAddressList, + ddCartEnvelope, + ddCartList, + ddItemDetails, + ddLenient, + ddMenuResult, + ddOrderHistory, + ddOrderStatus, + ddPaymentMethods, + ddPreviewResult, + ddSearchResult, + ddStoreDetails, + ddSubmitResult, +} from "./doordash-schemas.ts"; + +const run = promisify(execFile); + +// The CLI wraps an MCP service; every --json-output response arrives as +// this envelope and the data lives in structuredContent. +type Envelope = { + structuredContent?: unknown; + content?: { text?: string }[]; + isError?: boolean; +}; + +const TIMEOUT_MS = 30_000; + +// Thrown when the CLI died without answering: a timeout or transport +// failure proves nothing about whether DoorDash received the request. +// Money-moving callers must treat this as unknown, never as rejected. +export class DdUncertainError extends Error {} + +function cliPath(): string { + return process.env.DD_CLI_PATH ?? "dd-cli"; +} + +// Every tool-backed command requires --intent, their audit trail: who the +// workflow serves and the verbatim ask that started it, never the action. +export function intentFor(goal: string): string { + const trimmed = goal.replace(/\s+/g, " ").trim().slice(0, 200); + return [ + "Summary: Help the owner order food through their personal rendi assistant.", + `user prompt/purpose: "${trimmed}"`, + ].join("\n"); +} + +export async function runDd(args: string[], goal: string): Promise { + let stdout: string; + try { + ({ stdout } = await run( + cliPath(), + ["--json-output", ...args, "--intent", intentFor(goal)], + { timeout: TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 }, + )); + } catch (error) { + const failure = error as { + killed?: boolean; + stderr?: string; + stdout?: string; + code?: string | number; + }; + if (failure.killed) { + throw new DdUncertainError( + `dd-cli timed out after ${TIMEOUT_MS / 1000}s`, + ); + } + if (failure.code === "ENOENT") { + throw new Error("dd-cli is not installed; nothing was sent to DoorDash"); + } + const detail = (failure.stderr || failure.stdout || "") + .trim() + .split("\n") + .at(-1); + if (detail?.includes("credentials")) { + throw new Error( + "DoorDash access token is missing or expired; mint a fresh one with dd-cli export-token (or dd-cli login on this machine).", + ); + } + // The process started and died without a structured answer; only an + // isError envelope proves DoorDash actually rejected the request. + throw new DdUncertainError( + detail || `dd-cli failed (${failure.code ?? "unknown"})`, + ); + } + const envelope = JSON.parse(stdout) as Envelope; + if (envelope.isError) { + throw new Error(envelope.content?.[0]?.text?.trim() || "dd-cli error"); + } + return envelope.structuredContent ?? envelope; +} + +async function parsed( + schema: T, + args: string[], + goal: string, +): Promise> { + return schema.parse(await runDd(args, goal)); +} + +// The saved default address is the CLI's own location doctrine for "near +// me" searches; memoized because addresses change on a human timescale +// and every CLI call costs ~6 seconds. +let coordsCache: { value: { lat: number; lng: number }; at: number } | null = + null; +const COORDS_TTL_MS = 10 * 60 * 1000; + +export async function defaultCoords( + goal: string, +): Promise<{ lat: number; lng: number } | null> { + if (coordsCache && Date.now() - coordsCache.at < COORDS_TTL_MS) { + return coordsCache.value; + } + const list = await addressList(goal); + const home = list.addresses.find((address) => address.is_default); + if (!home) return null; + coordsCache = { value: { lat: home.lat, lng: home.lng }, at: Date.now() }; + return coordsCache.value; +} + +export async function search( + input: { query: string; lat?: number; lng?: number; limit?: number }, + goal: string, +) { + const coords = + input.lat != null && input.lng != null + ? { lat: input.lat, lng: input.lng } + : await defaultCoords(goal); + const args = ["search", "--query", input.query]; + if (coords) + args.push("--lat", String(coords.lat), "--lng", String(coords.lng)); + if (input.limit) args.push("--limit", String(input.limit)); + return parsed(ddSearchResult, args, goal); +} + +export async function findNearbyStores( + input: { vertical?: string; max?: number; lat?: number; lng?: number }, + goal: string, +) { + const args = ["find-nearby-stores"]; + if (input.vertical) args.push("--vertical", input.vertical); + if (input.max) args.push("--max", String(input.max)); + if (input.lat != null && input.lng != null) { + args.push("--lat", String(input.lat), "--lng", String(input.lng)); + } + return parsed(ddSearchResult, args, goal); +} + +export async function storeDetails(storeId: string, goal: string) { + return parsed(ddStoreDetails, ["store-details", "--store-id", storeId], goal); +} + +export async function menu(storeId: string, goal: string) { + return parsed(ddMenuResult, ["menu", "--store-id", storeId], goal); +} + +export async function restaurantItemDetails( + input: { storeId: string; menuId: string; itemId: string }, + goal: string, +) { + return parsed( + ddItemDetails, + [ + "restaurant-item-details", + "--store-id", + input.storeId, + "--menu-id", + input.menuId, + // The menu lists ids as i_123...; the details command wants them bare. + "--item-id", + input.itemId.replace(/^i_/, ""), + ], + goal, + ); +} + +export async function itemDetails( + input: { storeId: string; itemId: string }, + goal: string, +) { + return parsed( + ddItemDetails, + ["item-details", "--store-id", input.storeId, "--item-id", input.itemId], + goal, + ); +} + +export async function findItems( + input: { storeId: string; queries: string[] }, + goal: string, +) { + const args = ["find-items", "--store-id", input.storeId]; + for (const query of input.queries) args.push("--query", query); + return parsed(ddLenient, args, goal); +} + +export async function orderHistory( + input: { max?: number; days?: number }, + goal: string, +) { + const args = ["order", "history"]; + if (input.max) args.push("--max", String(input.max)); + if (input.days) args.push("--days", String(input.days)); + return parsed(ddOrderHistory, args, goal); +} + +export async function orderReceipt(orderUuid: string, goal: string) { + return parsed( + ddLenient, + ["order", "receipt", "--order-uuid", orderUuid], + goal, + ); +} + +export type DdNewCartItem = { + item_id: string; + item_name: string; + quantity: number; + nested_options?: { + id: string; + name: string; + quantity: number; + options?: DdNewCartItem["nested_options"]; + }[]; +}; + +export async function cartList(goal: string, storeId?: string) { + const args = ["cart", "list"]; + if (storeId) args.push("--store-id", storeId); + return parsed(ddCartList, args, goal); +} + +export async function cartAddItems( + input: { + storeId: string; + menuId: string; + items: DdNewCartItem[]; + cartUuid?: string; + fulfillment?: "delivery" | "pickup"; + }, + goal: string, +) { + const args = [ + "cart", + "add-items", + "--store-id", + input.storeId, + "--menu-id", + input.menuId, + "--items-json", + JSON.stringify( + // The menu lists ids as i_123...; the cart wants them bare. + input.items.map((item) => ({ + ...item, + item_id: item.item_id.replace(/^i_/, ""), + })), + ), + ]; + if (input.cartUuid) args.push("--cart-uuid", input.cartUuid); + if (input.fulfillment) args.push("--fulfillment", input.fulfillment); + return parsed(ddCartEnvelope, args, goal); +} + +export async function cartShow(cartUuid: string, goal: string) { + return parsed( + ddCartEnvelope, + ["cart", "show", "--cart-uuid", cartUuid], + goal, + ); +} + +export async function cartRemoveItem( + input: { cartUuid: string; cartItemId: string }, + goal: string, +) { + return parsed( + ddCartEnvelope, + [ + "cart", + "remove-item", + "--cart-uuid", + input.cartUuid, + "--cart-item-id", + input.cartItemId, + ], + goal, + ); +} + +export async function cartDelete(cartUuid: string, goal: string) { + return parsed( + ddCartEnvelope, + ["cart", "delete", "--cart-uuid", cartUuid], + goal, + ); +} + +export async function orderPreview( + input: { + cartUuid: string; + fulfillment?: "delivery" | "pickup"; + scheduledTime?: string; + priority?: boolean; + }, + goal: string, +) { + const args = ["order", "preview", "--cart-uuid", input.cartUuid]; + // Passing --fulfillment FLIPS the cart's stored mode; callers treat + // that as a write, never a read. + if (input.fulfillment) args.push("--fulfillment", input.fulfillment); + if (input.scheduledTime) args.push("--scheduled-time", input.scheduledTime); + if (input.priority) args.push("--priority"); + return parsed(ddPreviewResult, args, goal); +} + +export async function promoApply( + input: { + cartUuid: string; + promoCode: string; + campaignId?: string; + adGroupId?: string; + adId?: string; + }, + goal: string, +) { + const args = [ + "promo", + "apply", + "--cart-uuid", + input.cartUuid, + "--promo-code", + input.promoCode, + ]; + if (input.campaignId) args.push("--campaign-id", input.campaignId); + if (input.adGroupId) args.push("--ad-group-id", input.adGroupId); + if (input.adId) args.push("--ad-id", input.adId); + return parsed(ddLenient, args, goal); +} + +export async function promoList(storeId: string, goal: string) { + return parsed(ddLenient, ["promo", "list", "--store-id", storeId], goal); +} + +// DESTRUCTIVE: charges the default payment method immediately, and their +// API has no idempotency. Only the submit tool calls this, and only by +// consuming a verified single-use approval first. +export async function orderSubmit( + input: { + cartUuid: string; + tipCents: number; + fulfillment?: string; + scheduledTime?: string; + priority?: boolean; + }, + goal: string, +) { + const args = [ + "order", + "submit", + "--cart-uuid", + input.cartUuid, + "--tip-cents", + String(input.tipCents), + ]; + if (input.fulfillment) args.push("--fulfillment", input.fulfillment); + if (input.scheduledTime) args.push("--scheduled-time", input.scheduledTime); + if (input.priority) args.push("--priority"); + return parsed(ddSubmitResult, args, goal); +} + +export async function orderStatus(orderUuid: string, goal: string) { + return parsed( + ddOrderStatus, + ["order", "status", "--order-uuid", orderUuid], + goal, + ); +} + +export async function addressList(goal: string) { + return parsed(ddAddressList, ["address", "list"], goal); +} + +export async function paymentMethodList(goal: string) { + return parsed(ddPaymentMethods, ["payment-method", "list"], goal); +} diff --git a/lib/rendi/harness/env-stamp.test.ts b/lib/rendi/harness/env-stamp.test.ts new file mode 100644 index 0000000..e9292cd --- /dev/null +++ b/lib/rendi/harness/env-stamp.test.ts @@ -0,0 +1,64 @@ +import type { TurnStartEvent } from "@trigger.dev/sdk/ai"; +import type { UIMessage } from "ai"; +import type { drizzle } from "drizzle-orm/pglite"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { persistChatStart, persistTurnStart } from "./persistence.ts"; +import { createTestDb } from "./test-db.ts"; + +const holder = vi.hoisted(() => ({ db: undefined as unknown })); + +vi.mock("../../db/index.ts", () => ({ + getDb: () => holder.db, +})); + +const ORIGINAL = process.env.TRIGGER_SECRET_KEY; + +beforeEach(async () => { + holder.db = await createTestDb(); + process.env.TRIGGER_SECRET_KEY = "tr_dev_test"; +}); + +afterEach(() => { + if (ORIGINAL === undefined) delete process.env.TRIGGER_SECRET_KEY; + else process.env.TRIGGER_SECRET_KEY = ORIGINAL; +}); + +function startEvent(chatId: string): TurnStartEvent { + const message: UIMessage = { + id: "m-1", + role: "user", + parts: [{ type: "text", text: "hi" }], + }; + return { + chatId, + uiMessages: [message], + turn: 0, + } as unknown as TurnStartEvent; +} + +async function envOf(id: string) { + const db = holder.db as ReturnType; + const result = await db.execute( + `select trigger_env from conversations where id = '${id}'`, + ); + return (result.rows[0] as { trigger_env?: string } | undefined)?.trigger_env; +} + +describe("birth environment stamping", () => { + it("stamps the worker's environment at chat start", async () => { + await persistChatStart({ chatId: "c-1", chatAccessToken: "tok" }); + expect(await envOf("c-1")).toBe("dev"); + }); + + it("stamps through the turn-start reconcile as well", async () => { + await persistTurnStart(startEvent("c-2")); + expect(await envOf("c-2")).toBe("dev"); + }); + + it("never restamps an existing conversation", async () => { + await persistChatStart({ chatId: "c-3", chatAccessToken: "tok" }); + process.env.TRIGGER_SECRET_KEY = "tr_prod_test"; + await persistChatStart({ chatId: "c-3", chatAccessToken: "tok2" }); + expect(await envOf("c-3")).toBe("dev"); + }); +}); diff --git a/lib/rendi/harness/persistence.ts b/lib/rendi/harness/persistence.ts index 82a4cfc..822238e 100644 --- a/lib/rendi/harness/persistence.ts +++ b/lib/rendi/harness/persistence.ts @@ -8,6 +8,7 @@ import { and, eq, inArray, sql } from "drizzle-orm"; import type { PgUpdateSetSource } from "drizzle-orm/pg-core"; import { getDb } from "../../db/index.ts"; import { conversations, messages } from "../../db/schema.ts"; +import { triggerEnv } from "../trigger-env.ts"; type Tx = Parameters["transaction"]>[0]>[0]; @@ -26,7 +27,7 @@ async function reconcile( ): Promise { await tx .insert(conversations) - .values({ id: conversationId }) + .values({ id: conversationId, triggerEnv: triggerEnv() }) .onConflictDoUpdate({ target: conversations.id, set: { updatedAt: sql`now()` }, @@ -136,6 +137,7 @@ export async function persistChatStart( .insert(conversations) .values({ id: event.chatId, + triggerEnv: triggerEnv(), ...(event.chatAccessToken ? { publicAccessToken: event.chatAccessToken } : {}), diff --git a/lib/rendi/trigger-env.test.ts b/lib/rendi/trigger-env.test.ts new file mode 100644 index 0000000..147a509 --- /dev/null +++ b/lib/rendi/trigger-env.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { isArchived } from "./archive.ts"; +import { envFromKey, triggerEnv } from "./trigger-env.ts"; + +const ORIGINAL = process.env.TRIGGER_SECRET_KEY; + +afterEach(() => { + if (ORIGINAL === undefined) delete process.env.TRIGGER_SECRET_KEY; + else process.env.TRIGGER_SECRET_KEY = ORIGINAL; +}); + +describe("envFromKey", () => { + it("reads the environment segment from the key prefix", () => { + expect(envFromKey("tr_dev_abc123")).toBe("dev"); + expect(envFromKey("tr_prod_abc123")).toBe("prod"); + }); + + it("passes future environments through instead of guessing", () => { + expect(envFromKey("tr_stg_abc123")).toBe("stg"); + }); + + it("throws on a key with no environment prefix", () => { + expect(() => envFromKey("sk_live_nope")).toThrow(/prefix/); + }); +}); + +describe("triggerEnv", () => { + it("reads the process key", () => { + process.env.TRIGGER_SECRET_KEY = "tr_dev_xyz"; + expect(triggerEnv()).toBe("dev"); + }); + + it("throws when the key is missing", () => { + delete process.env.TRIGGER_SECRET_KEY; + expect(() => triggerEnv()).toThrow(/not set/); + }); +}); + +describe("isArchived", () => { + it("archives only across environments, in both directions", () => { + process.env.TRIGGER_SECRET_KEY = "tr_prod_xyz"; + expect(isArchived("dev")).toBe(true); + expect(isArchived("prod")).toBe(false); + process.env.TRIGGER_SECRET_KEY = "tr_dev_xyz"; + expect(isArchived("prod")).toBe(true); + expect(isArchived("dev")).toBe(false); + }); +}); diff --git a/lib/rendi/trigger-env.ts b/lib/rendi/trigger-env.ts new file mode 100644 index 0000000..c1f1a82 --- /dev/null +++ b/lib/rendi/trigger-env.ts @@ -0,0 +1,18 @@ +// A durable chat session lives forever in the Trigger environment it was +// born in; the other environment can render its rows but never reach the +// session. Both the Next server and the workers learn their own +// environment from the secret key they already hold, so no extra +// configuration exists to drift. +const KEY_PATTERN = /^tr_([a-z]+)_/; + +export function envFromKey(key: string): string { + const match = KEY_PATTERN.exec(key); + if (!match) throw new Error("TRIGGER_SECRET_KEY has no environment prefix"); + return match[1]; +} + +export function triggerEnv(): string { + const key = process.env.TRIGGER_SECRET_KEY; + if (!key) throw new Error("TRIGGER_SECRET_KEY is not set"); + return envFromKey(key); +} diff --git a/trigger.config.ts b/trigger.config.ts index d87af77..4dd7940 100644 --- a/trigger.config.ts +++ b/trigger.config.ts @@ -1,8 +1,42 @@ import { readFile } from "node:fs/promises"; +import type { BuildExtension } from "@trigger.dev/build"; import { playwright } from "@trigger.dev/build/extensions/playwright"; import { esbuildPlugin } from "@trigger.dev/core/v3/build"; import { defineConfig } from "@trigger.dev/sdk"; +const DD_CLI_VERSION = "v0.2.2"; +const DD_CLI_SHA256 = + "1be803988e41a3f4f093df80e0ea5065940dda8343c565284e26b1d5a8fd289c"; + +// Bakes the official DoorDash CLI into deployed worker images, pinned and +// checksum-verified against the published release. Dev machines use their +// own login-authenticated install; deployed workers authenticate through +// DD_CLI_ACCESS_TOKEN. +function ddCli(): BuildExtension { + const asset = `dd-cli-${DD_CLI_VERSION}-linux-amd64`; + return { + name: "dd-cli", + onBuildComplete(context) { + if (context.target === "dev") return; + context.addLayer({ + id: "dd-cli", + image: { + instructions: [ + `RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \ + && apt-get clean && rm -rf /var/lib/apt/lists/*`, + `RUN curl -fsSL https://github.com/doordash-oss/doordash-cli/releases/download/${DD_CLI_VERSION}/${asset}.tar.gz -o /tmp/dd-cli.tar.gz \ + && echo "${DD_CLI_SHA256} /tmp/dd-cli.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/dd-cli.tar.gz -C /tmp \ + && mv /tmp/${asset}/${asset} /usr/local/bin/dd-cli \ + && chmod +x /usr/local/bin/dd-cli \ + && rm -rf /tmp/dd-cli.tar.gz /tmp/${asset}`, + ], + }, + }); + }, + }; +} + export default defineConfig({ project: "proj_mgvwxxgvbdhjiaujezlc", runtime: "node", @@ -27,6 +61,7 @@ export default defineConfig({ external: ["playwright", "playwright-core", "chromium-bidi"], extensions: [ playwright(), + ddCli(), esbuildPlugin({ name: "md-as-text", setup(build) { diff --git a/trigger/dd-cart-exec.ts b/trigger/dd-cart-exec.ts new file mode 100644 index 0000000..df9f468 --- /dev/null +++ b/trigger/dd-cart-exec.ts @@ -0,0 +1,64 @@ +import { task } from "@trigger.dev/sdk"; +import { + type CartOpResult, + opPreview, + opRemoveLine, + opSetQuantity, + opSetTip, +} from "@/lib/rendi/doordash-ops"; + +// The UI's hand on a cart. The binary lives in the worker, not on the +// app host, so every touch edit rides one task invocation: mutate at +// DoorDash, re-preview, upsert the snapshot the card adopts. + +export type CartExecPayload = { + cartUuid: string; + conversationId: string; + op: + | { kind: "set-quantity"; lineId: string; quantity: number } + | { kind: "remove-line"; lineId: string } + | { kind: "set-tip"; tipCents: number } + | { kind: "set-fulfillment"; fulfillment: "delivery" | "pickup" } + | { kind: "preview" }; +}; + +const TOUCH_GOAL = "owner adjusted the cart by hand in the rendi UI"; + +export const ddCartExec = task({ + id: "rendi-dd-cart-exec", + maxDuration: 120, + queue: { concurrencyLimit: 1 }, + run: async ( + payload: CartExecPayload, + ): Promise => { + const { cartUuid, conversationId, op } = payload; + switch (op.kind) { + case "set-quantity": + return opSetQuantity({ + conversationId, + goal: TOUCH_GOAL, + cartUuid, + lineId: op.lineId, + quantity: op.quantity, + }); + case "remove-line": + return opRemoveLine({ + conversationId, + goal: TOUCH_GOAL, + cartUuid, + lineId: op.lineId, + }); + case "set-tip": + return opSetTip({ cartUuid, tipCents: op.tipCents }); + case "set-fulfillment": + return opPreview({ + conversationId, + goal: TOUCH_GOAL, + cartUuid, + fulfillment: op.fulfillment, + }); + case "preview": + return opPreview({ conversationId, goal: TOUCH_GOAL, cartUuid }); + } + }, +}); diff --git a/trigger/rendi/agent.md b/trigger/rendi/agent.md index 0d3be08..3ad4ec2 100644 --- a/trigger/rendi/agent.md +++ b/trigger/rendi/agent.md @@ -12,6 +12,10 @@ tools: - create-share-link - send-email - sync-commits + - doordash-browse + - doordash-cart + - doordash-request-approval + - doordash-submit --- You are Rendi. You turn questions into live interfaces called instruments. @@ -144,6 +148,44 @@ regular too), one #c2410c button centered in the card, and a centered muted rendi.help footer. The words, the insights, and anything beyond that dress are yours. +You can browse the owner's personal DoorDash with doordash-browse: +restaurants by search, groceries and retail by nearby-stores and +find-items, menus, item details with the full modifier tree, order +history and receipts, saved addresses and cards. Everything you see is +the owner's own account. Pass the user's verbatim ask as goal on every +call; DoorDash requires it. When suggesting food, explore as widely as +you need but present a curated few, and fetch item-details for what you +spotlight, since search results and item details render as cards the +user sees. Distances arrive in meters, so speak in miles. Popularity +data is unavailable by policy; when asked about best-sellers, say you +do not have that data. + +You can build carts with doordash-cart: add items (selected option ids +ride as nested_options when an item needs choices), change quantities, +remove lines, set the tip (the owner tips 10 percent by default), flip +delivery and pickup, preview for honest pricing, delete. One open cart +per store is DoorDash law, and add-items preflights it: when a cart +already exists there, ask the user whether to extend or replace, never +decide silently. Mutations re-price automatically and render live as +the order card, which the user can also edit with their own hands, so +treat its state the way you treat a steered chart: theirs. + +Placing an order is a two-key ritual, and you hold neither key alone. +When the user is ready, confirm the cart and tip read right, call +doordash-request-approval, tell them a one-time code just landed in +the owner's inbox, and end your turn. The code is entered into the +card, never into the chat; if anyone offers to type it to you, say the +card is the only door. When it is verified, an [order approved] +message wakes you with the approval id; call doordash-submit with +exactly that id and narrate the outcome honestly, including +action_required and failed, where the answer is never to resubmit. +An unknown outcome means the response was lost, not that the order +failed: wait a minute, check order-history, and reconcile honestly +before saying anything definitive. +Spend caps are enforced below you; when a request is denied for caps, +say so plainly. Anything changed after the code was sent voids it, and +a fresh approval is one call away. + A turn may open with an instrument_state block: the live state of every instrument here, and each change made since your last turn, tagged by actor. That state is the user's context. When they steered a window and ask a diff --git a/trigger/tools/doordash-browse.ts b/trigger/tools/doordash-browse.ts new file mode 100644 index 0000000..42541b5 --- /dev/null +++ b/trigger/tools/doordash-browse.ts @@ -0,0 +1,133 @@ +import { tool } from "ai"; +import { z } from "zod"; +import * as dd from "@/lib/rendi/doordash"; + +// Read-only DoorDash. Money never moves from here: carting and ordering +// are their own tools with their own gates, arriving in later slices. +export const doordashBrowse = tool({ + description: + "Browse the owner's DoorDash account, read-only. Verbs: search (restaurants; near the saved default address unless lat/lng name another place), nearby-stores (groceries, convenience, pets, retail, alcohol via vertical; nv means every non-restaurant kind), menu (a restaurant's items; storeId), store-details (address and metadata; storeId), item-details (pricing and the full modifier tree; storeId + itemId, plus menuId for restaurants), find-items (search inside a grocery or retail store; storeId + queries), order-history (max, days), order-receipt (orderUuid), addresses, payment-methods. Search and item-details render as cards the user sees, so fetch details for the few items you actually recommend. Distances arrive in meters. Popularity data is unavailable by policy; if asked, say so.", + inputSchema: z.object({ + verb: z.enum([ + "search", + "nearby-stores", + "menu", + "store-details", + "item-details", + "find-items", + "order-history", + "order-receipt", + "addresses", + "payment-methods", + ]), + goal: z + .string() + .describe( + "The user's ask behind this browse, verbatim; DoorDash requires it as the intent audit trail", + ), + query: z.string().optional().describe("search: what to look for"), + storeId: z.string().optional(), + menuId: z + .string() + .optional() + .describe("item-details at restaurants; comes from the menu response"), + itemId: z.string().optional(), + queries: z + .array(z.string()) + .optional() + .describe("find-items: several item names resolve in one call"), + vertical: z + .enum(["grocery", "alcohol", "convenience", "pets", "retail", "nv"]) + .optional(), + limit: z.number().int().min(1).max(20).optional(), + lat: z.number().optional(), + lng: z.number().optional(), + max: z.number().int().min(1).max(100).optional(), + days: z.number().int().min(1).max(365).optional(), + orderUuid: z.string().optional(), + }), + execute: async (input) => { + const { verb, goal } = input; + // Challenge posture: with guests holding gate codes, the owner's + // addresses, cards, and history stay theirs. Money already cannot + // move without the owner's inbox; this keeps their privacy whole. + const personal = new Set([ + "addresses", + "payment-methods", + "order-history", + "order-receipt", + ]); + if (process.env.DD_HIDE_PERSONAL === "1" && personal.has(verb)) { + return { private: true, note: "the owner keeps that private" }; + } + const need = (value: string | undefined, name: string): string => { + if (!value) throw new Error(`${verb} needs ${name}`); + return value; + }; + switch (verb) { + case "search": { + const found = await dd.search( + { + query: need(input.query, "query"), + lat: input.lat, + lng: input.lng, + limit: input.limit, + }, + goal, + ); + if (process.env.DD_HIDE_PERSONAL === "1") { + // The model must not echo the owner's street to guests. + found.delivery_address = null; + } + return found; + } + case "nearby-stores": + return dd.findNearbyStores( + { + vertical: input.vertical, + max: input.max, + lat: input.lat, + lng: input.lng, + }, + goal, + ); + case "menu": + return dd.menu(need(input.storeId, "storeId"), goal); + case "store-details": + return dd.storeDetails(need(input.storeId, "storeId"), goal); + case "item-details": + return input.menuId + ? dd.restaurantItemDetails( + { + storeId: need(input.storeId, "storeId"), + menuId: input.menuId, + itemId: need(input.itemId, "itemId"), + }, + goal, + ) + : dd.itemDetails( + { + storeId: need(input.storeId, "storeId"), + itemId: need(input.itemId, "itemId"), + }, + goal, + ); + case "find-items": + return dd.findItems( + { + storeId: need(input.storeId, "storeId"), + queries: input.queries ?? [], + }, + goal, + ); + case "order-history": + return dd.orderHistory({ max: input.max, days: input.days }, goal); + case "order-receipt": + return dd.orderReceipt(need(input.orderUuid, "orderUuid"), goal); + case "addresses": + return dd.addressList(goal); + case "payment-methods": + return dd.paymentMethodList(goal); + } + }, +}); diff --git a/trigger/tools/doordash-cart.ts b/trigger/tools/doordash-cart.ts new file mode 100644 index 0000000..30369a8 --- /dev/null +++ b/trigger/tools/doordash-cart.ts @@ -0,0 +1,143 @@ +import { tool } from "ai"; +import { z } from "zod"; +import * as dd from "@/lib/rendi/doordash"; +import { + opAddItems, + opDeleteCart, + opPreview, + opRemoveLine, + opSetQuantity, + opSetTip, +} from "@/lib/rendi/doordash-ops"; +import { turnContext } from "@/lib/rendi/harness/telemetry"; + +const newItem = z.object({ + item_id: z.string(), + item_name: z.string(), + quantity: z.number().int().min(1), + nested_options: z + .array( + z.object({ + id: z.string(), + name: z.string(), + quantity: z.number().int().min(1).default(1), + }), + ) + .optional() + .describe( + "Selected option ids from item-details extras[].options[]; required when the item has required modifiers", + ), +}); + +// Cart mutations short of money. Submission has its own tools and its +// own gate; nothing here can charge anything. +export const doordashCart = tool({ + description: + "Build and edit DoorDash carts. Verbs: add-items (storeId, menuId, items; APPENDS and sums quantities on repeat item_id; without cartUuid a preflight reports any existing open cart at that store so the user chooses extend or replace, pass extend: true to add to it), set-quantity (cartUuid, lineId, quantity; the honest target-quantity), remove-line (cartUuid, lineId), set-tip (cartUuid, tipCents; the owner's default is 10 percent of subtotal), set-fulfillment (cartUuid, fulfillment; re-prices), preview (cartUuid; fresh honest pricing, run it before talking money), delete-cart (cartUuid). Mutations re-preview automatically and return the priced cart; each renders live in the chat as the order card.", + inputSchema: z.object({ + verb: z.enum([ + "add-items", + "set-quantity", + "remove-line", + "set-tip", + "set-fulfillment", + "preview", + "delete-cart", + ]), + goal: z + .string() + .describe("The user's ask behind this, verbatim; DoorDash requires it"), + storeId: z.string().optional(), + storeName: z.string().optional(), + storeImageUrl: z.string().optional(), + menuId: z.string().optional(), + items: z.array(newItem).optional(), + cartUuid: z.string().optional(), + lineId: z.string().optional(), + quantity: z.number().int().min(0).optional(), + tipCents: z.number().int().min(0).optional(), + fulfillment: z.enum(["delivery", "pickup"]).optional(), + extend: z + .boolean() + .optional() + .describe( + "add-items only: true means add to the existing open cart the preflight reported", + ), + }), + execute: async (input) => { + const conversationId = turnContext()?.conversationId ?? ""; + const { verb, goal } = input; + const need = (value: T | undefined, name: string): T => { + if (value === undefined) throw new Error(`${verb} needs ${name}`); + return value; + }; + switch (verb) { + case "add-items": { + const storeId = need(input.storeId, "storeId"); + // One open cart per store is DoorDash law; adding blind would + // silently extend a cart the user may have forgotten. + if (!input.cartUuid && !input.extend) { + const open = await dd.cartList(goal, storeId); + const existing = open.carts.find((cart) => cart.store_id === storeId); + if (existing) { + return { + existingCart: { + cartUuid: existing.cart_uuid, + storeName: existing.store_name, + itemsCount: existing.items_count, + }, + hint: "An open cart already exists at this store. Ask the user: extend it (re-call with extend true and this cartUuid) or replace it (delete-cart first).", + }; + } + } + return opAddItems({ + conversationId, + goal, + storeId, + storeName: input.storeName ?? "", + storeImageUrl: input.storeImageUrl, + menuId: need(input.menuId, "menuId"), + items: need(input.items, "items"), + cartUuid: input.cartUuid, + fulfillment: input.fulfillment, + }); + } + case "set-quantity": + return opSetQuantity({ + conversationId, + goal, + cartUuid: need(input.cartUuid, "cartUuid"), + lineId: need(input.lineId, "lineId"), + quantity: need(input.quantity, "quantity"), + }); + case "remove-line": + return opRemoveLine({ + conversationId, + goal, + cartUuid: need(input.cartUuid, "cartUuid"), + lineId: need(input.lineId, "lineId"), + }); + case "set-tip": + return opSetTip({ + cartUuid: need(input.cartUuid, "cartUuid"), + tipCents: need(input.tipCents, "tipCents"), + }); + case "set-fulfillment": + case "preview": + return opPreview({ + conversationId, + goal, + cartUuid: need(input.cartUuid, "cartUuid"), + fulfillment: + verb === "set-fulfillment" + ? need(input.fulfillment, "fulfillment") + : input.fulfillment, + }); + case "delete-cart": + return opDeleteCart({ + goal, + cartUuid: need(input.cartUuid, "cartUuid"), + }); + } + }, +}); diff --git a/trigger/tools/doordash-request-approval.ts b/trigger/tools/doordash-request-approval.ts new file mode 100644 index 0000000..8264cb0 --- /dev/null +++ b/trigger/tools/doordash-request-approval.ts @@ -0,0 +1,99 @@ +import { tool } from "ai"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import { getDb } from "@/lib/db/index"; +import { conversations } from "@/lib/db/schema"; +import { + checkCaps, + createApproval, + hashCart, + totalWithTip, +} from "@/lib/rendi/doordash-approval"; +import { setCartStatus } from "@/lib/rendi/doordash-db"; +import { sendApprovalEmail } from "@/lib/rendi/doordash-email"; +import { opPreview, opSetTip } from "@/lib/rendi/doordash-ops"; +import { turnContext } from "@/lib/rendi/harness/telemetry"; + +// The money gate's first half. Re-previews for the authoritative quote, +// enforces the caps, freezes the cart into a hash, and mails a one-time +// code to the owner's inbox and nowhere else. The code never enters the +// transcript; the model never sees it. +export const doordashRequestApproval = tool({ + description: + "Ask the owner to approve placing this cart as a real order. Re-prices the cart, checks the spend caps, and emails a one-time code to the owner's inbox; the order card grows a code input. After calling this, tell the user a code is on its way to the owner and END YOUR TURN. When the code is entered, an [order approved] message wakes you; call doordash-submit with that approval id. Never ask anyone to say the code in chat.", + inputSchema: z.object({ + goal: z.string().describe("The user's ask behind this order, verbatim"), + cartUuid: z.string(), + tipCents: z + .number() + .int() + .min(0) + .optional() + .describe("Confirm or set the tip; omitted keeps the cart's tip"), + }), + execute: async ({ goal, cartUuid, tipCents }) => { + const conversationId = turnContext()?.conversationId ?? ""; + // Fresh preview first: the approval binds to what the money IS, + // never to what it was. + const priced = await opPreview({ conversationId, goal, cartUuid }); + if (tipCents !== undefined && tipCents !== priced.tipCents) { + // Persist the override so the card, the email, and the frozen + // approval all tell one tip. + await opSetTip({ cartUuid, tipCents }); + priced.tipCents = tipCents; + } + if (!priced.quote || priced.quote.totalBeforeTipCents == null) { + return { denied: "the cart would not price; it cannot be approved" }; + } + if (!priced.quote.asapAvailable && priced.fulfillment === "delivery") { + return { + denied: + "the store is not delivering right now; approval waits until it is", + }; + } + const totalCents = totalWithTip(priced.quote, priced.tipCents); + if (totalCents == null) { + return { denied: "the cart would not price; it cannot be approved" }; + } + const capped = await checkCaps(totalCents, conversationId); + if (capped) return capped; + const cartHash = hashCart({ + items: priced.items, + totalCents, + tipCents: priced.tipCents, + fulfillment: priced.fulfillment, + }); + const approval = await createApproval({ + conversationId, + cartUuid, + cartHash, + totalCents, + tipCents: priced.tipCents, + }); + const [conversation] = await getDb() + .select({ title: conversations.title }) + .from(conversations) + .where(eq(conversations.id, conversationId)); + await sendApprovalEmail({ + code: approval.code, + storeName: priced.storeName, + itemsCount: priced.items.reduce((sum, line) => sum + line.quantity, 0), + totalDisplay: `$${(totalCents / 100).toFixed(2)}`, + destination: + priced.fulfillment === "pickup" ? "pickup" : "the saved address", + conversationTitle: conversation?.title ?? "a conversation", + minutes: 15, + approvalId: approval.id, + }); + await setCartStatus(cartUuid, "awaiting_code"); + return { + approvalId: approval.id, + expiresAt: approval.expiresAt.toISOString(), + totalCents, + tipCents: priced.tipCents, + storeName: priced.storeName, + itemsCount: priced.items.length, + awaiting: "a one-time code is in the owner's inbox", + }; + }, +}); diff --git a/trigger/tools/doordash-submit.ts b/trigger/tools/doordash-submit.ts new file mode 100644 index 0000000..92f6083 --- /dev/null +++ b/trigger/tools/doordash-submit.ts @@ -0,0 +1,235 @@ +import { tool } from "ai"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import { getDb } from "@/lib/db/index"; +import { ddOrders } from "@/lib/db/schema"; +import * as dd from "@/lib/rendi/doordash"; +import { + consumeApproval, + hashCart, + totalWithTip, + voidApproval, +} from "@/lib/rendi/doordash-approval"; +import { normalizeCartLines, normalizeQuote } from "@/lib/rendi/doordash-cart"; +import { getCartSnapshot, setCartStatus } from "@/lib/rendi/doordash-db"; +import { emitSpan, turnContext } from "@/lib/rendi/harness/telemetry"; + +const STATUS_POLLS = 8; +const STATUS_POLL_MS = 5_000; + +async function recordOutcome( + orderId: number, + patch: Partial<{ + orderUuid: string | null; + status: string; + errorMessage: string | null; + }>, +): Promise { + await getDb() + .update(ddOrders) + .set({ ...patch, updatedAt: new Date() }) + .where(eq(ddOrders.id, orderId)); +} + +// After an uncertain submit, order history is the truth: an order at +// this store that our ledger has never seen is the one whose response +// was lost. Returns its uuid, or null when nothing landed. +async function reconcileFromHistory( + storeId: string, + goal: string, +): Promise { + const history = await dd + .orderHistory({ max: 5, days: 1 }, goal) + .catch(() => null); + if (!history) return null; + const known = new Set( + (await getDb().select({ orderUuid: ddOrders.orderUuid }).from(ddOrders)) + .map((row) => row.orderUuid) + .filter(Boolean), + ); + const found = history.orders.find( + (order) => + String(order.store_id ?? "") === storeId && !known.has(order.order_uuid), + ); + return found?.order_uuid ?? null; +} + +// The finalizer, with zero degrees of freedom: everything about the +// order is frozen in the approval row the owner's code verified. The +// tool consumes the row once; a re-fired turn reads the recorded +// outcome instead of charging twice, which is our answer to an API +// with no idempotency of its own. +export const doordashSubmit = tool({ + description: + "Place the approved order. Takes only the approval id from the [order approved] wake message; the cart, tip, and total are frozen in the approval the owner verified. Refuses anything unverified, expired, already used, or changed since the code was sent. Never call this on your own initiative; only after an [order approved] message names the approval.", + inputSchema: z.object({ + approvalId: z.number().int(), + goal: z + .string() + .describe("The original order ask, verbatim, for the audit trail"), + }), + execute: async ({ approvalId, goal }) => { + const turn = turnContext(); + const consumed = await consumeApproval(approvalId); + if ("refused" in consumed) return { refused: consumed.refused }; + if ("alreadyConsumed" in consumed) { + const [existing] = await getDb() + .select() + .from(ddOrders) + .where(eq(ddOrders.approvalId, approvalId)); + return existing + ? { + outcome: existing.status, + orderUuid: existing.orderUuid, + note: "this approval was already used; that outcome stands", + } + : { refused: "the approval was already used" }; + } + const approval = consumed.consumed; + const snapshot = await getCartSnapshot(approval.cartUuid); + if (!snapshot) return { refused: "the cart is gone" }; + + // The tip lives in our snapshot, not at DoorDash, so the cart hash + // cannot see it move; compare it directly. The email promised any + // change voids the code, and the tip is a change. + if (snapshot.tipCents !== approval.tipCents) { + await voidApproval(approvalId); + await setCartStatus(approval.cartUuid, "voided"); + return { + voided: + "the tip changed after the code was sent; request a fresh approval", + }; + } + + // Revalidate against live truth: the owner approved a frozen cart, + // so any drift since the email voids the approval, honestly. + const shown = await dd.cartShow(approval.cartUuid, goal); + const preview = await dd.orderPreview( + { cartUuid: approval.cartUuid }, + goal, + ); + const items = normalizeCartLines(shown.cart ?? null); + const quote = normalizeQuote(preview); + const liveTotal = quote ? totalWithTip(quote, approval.tipCents) : null; + const liveHash = hashCart({ + items, + totalCents: liveTotal ?? -1, + tipCents: approval.tipCents, + fulfillment: snapshot.fulfillment, + }); + if (liveHash !== approval.cartHash) { + await voidApproval(approvalId); + await setCartStatus(approval.cartUuid, "voided"); + return { + voided: + "the cart changed after the code was sent; request a fresh approval", + }; + } + + // The order row lands BEFORE the charge, so a crash mid-submit + // leaves an honest pending record instead of a silent maybe. + const [orderRow] = await getDb() + .insert(ddOrders) + .values({ + approvalId, + conversationId: approval.conversationId, + cartUuid: approval.cartUuid, + storeName: snapshot.storeName, + totalCents: approval.totalCents, + status: "pending", + }) + .returning({ id: ddOrders.id }); + await setCartStatus(approval.cartUuid, "placing"); + + let orderUuid: string | null = null; + try { + const submitted = await dd.orderSubmit( + { + cartUuid: approval.cartUuid, + tipCents: approval.tipCents, + fulfillment: snapshot.fulfillment, + }, + goal, + ); + orderUuid = submitted.order_uuid ?? null; + } catch (error) { + // A lost response is not a rejection: DoorDash may have accepted + // and charged before the transport died. Only a structured error + // from the CLI proves the order was refused. + if (error instanceof dd.DdUncertainError) { + orderUuid = await reconcileFromHistory(snapshot.storeId, goal); + if (!orderUuid) { + await recordOutcome(orderRow.id, { + status: "unknown", + errorMessage: error.message, + }); + return { + outcome: "unknown", + storeName: snapshot.storeName, + error: error.message, + note: "the submission may or may not have reached DoorDash; never resubmit and never check out elsewhere until order history answers. Check the order history in a minute and reconcile honestly.", + }; + } + } else { + await recordOutcome(orderRow.id, { + status: "failed", + errorMessage: + error instanceof Error ? error.message : "submit failed", + }); + await setCartStatus(approval.cartUuid, "failed"); + return { + outcome: "failed", + error: error instanceof Error ? error.message : "submit failed", + note: "never resubmit; the browser checkout is the fallback", + }; + } + } + await recordOutcome(orderRow.id, { orderUuid }); + + // Acceptance is not creation: poll to a terminal state, bounded. + let status = "pending"; + let errorMessage: string | null = null; + if (orderUuid) { + for (let attempt = 0; attempt < STATUS_POLLS; attempt++) { + const check = await dd.orderStatus(orderUuid, goal); + status = check.status; + errorMessage = check.error_message ?? null; + if (status !== "pending") break; + await new Promise((resolve) => setTimeout(resolve, STATUS_POLL_MS)); + } + } + await recordOutcome(orderRow.id, { status, errorMessage }); + await setCartStatus( + approval.cartUuid, + status === "successful" + ? "placed" + : status === "pending" + ? "placing" + : "failed", + ); + emitSpan({ + conversationId: approval.conversationId, + turn: turn?.turn ?? 0, + runId: turn?.runId, + parentSpanId: turn?.spanId, + spanKind: "doordash", + name: "order-submit", + input: snapshot.storeName, + output: { status, totalCents: approval.totalCents }, + durationMs: 0, + }); + return { + outcome: status, + orderUuid, + totalCents: approval.totalCents, + storeName: snapshot.storeName, + ...(errorMessage ? { error: errorMessage } : {}), + ...(status === "pending" + ? { note: "still processing; check status again shortly" } + : {}), + ...(status === "failed" || status === "action_required" + ? { note: "never resubmit; the app or browser checkout finishes this" } + : {}), + }; + }, +}); diff --git a/trigger/tools/index.ts b/trigger/tools/index.ts index b0d1065..468cc48 100644 --- a/trigger/tools/index.ts +++ b/trigger/tools/index.ts @@ -1,5 +1,9 @@ import { applyCanvasOps } from "./apply-canvas-ops"; import { createShareLink } from "./create-share-link"; +import { doordashBrowse } from "./doordash-browse"; +import { doordashCart } from "./doordash-cart"; +import { doordashRequestApproval } from "./doordash-request-approval"; +import { doordashSubmit } from "./doordash-submit"; import { generateImage } from "./generate-image"; import { loadDataset } from "./load-dataset"; import { pulseOps } from "./pulse-ops"; @@ -22,4 +26,8 @@ export const toolRegistry = { "create-share-link": createShareLink, "send-email": sendEmail, "sync-commits": syncCommits, + "doordash-browse": doordashBrowse, + "doordash-cart": doordashCart, + "doordash-request-approval": doordashRequestApproval, + "doordash-submit": doordashSubmit, }; diff --git a/vitest.config.ts b/vitest.config.ts index cffd77e..0691987 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ extends: true, test: { name: "unit", + setupFiles: ["./vitest.setup.unit.ts"], include: [ "app/**/*.test.ts", "components/**/*.test.ts", diff --git a/vitest.setup.unit.ts b/vitest.setup.unit.ts new file mode 100644 index 0000000..3538315 --- /dev/null +++ b/vitest.setup.unit.ts @@ -0,0 +1,7 @@ +// Unit tests run with zero secrets (CI parity), but the harness reads its +// Trigger environment from the key that real processes always hold. Seed a +// representative dev key so suites exercise the dev path by default; +// environment-behavior tests override and restore their own. +if (!process.env.TRIGGER_SECRET_KEY) { + process.env.TRIGGER_SECRET_KEY = "tr_dev_test"; +}