diff --git a/apps/dashboard/app/(dashboard)/studio/deploy/page.tsx b/apps/dashboard/app/(dashboard)/studio/deploy/page.tsx new file mode 100644 index 0000000..266514b --- /dev/null +++ b/apps/dashboard/app/(dashboard)/studio/deploy/page.tsx @@ -0,0 +1,21 @@ +import { notFound } from "next/navigation"; +import { PageHeader } from "../../../../components/page-header"; +import { DeployPanel } from "../../../../features/products/deploy-panel"; +import { getOrCreateProduct } from "../../../../features/products/queries"; + +export const dynamic = "force-dynamic"; + +export default async function StudioDeployPage() { + const product = await getOrCreateProduct(); + if (!product) notFound(); + + return ( +
+ + +
+ ); +} diff --git a/apps/dashboard/app/(dashboard)/studio/inbox/page.tsx b/apps/dashboard/app/(dashboard)/studio/inbox/page.tsx new file mode 100644 index 0000000..f5cd296 --- /dev/null +++ b/apps/dashboard/app/(dashboard)/studio/inbox/page.tsx @@ -0,0 +1,24 @@ +import { PageHeader } from "../../../../components/page-header"; +import { getOrCreateProduct } from "../../../../features/products/queries"; + +export const dynamic = "force-dynamic"; + +export default async function StudioInboxPage() { + // Ensure the single agent exists so nav into Inbox is consistent with other Studio pages. + await getOrCreateProduct(); + + return ( +
+ +
+

Conversations coming soon

+

+ Chats, actions taken, and live handoff will land here in a later task. +

+
+
+ ); +} diff --git a/apps/dashboard/app/(dashboard)/studio/page.tsx b/apps/dashboard/app/(dashboard)/studio/page.tsx new file mode 100644 index 0000000..ec6e9e6 --- /dev/null +++ b/apps/dashboard/app/(dashboard)/studio/page.tsx @@ -0,0 +1,8 @@ +import { redirect } from "next/navigation"; + +export const dynamic = "force-dynamic"; + +/** /studio → Train (single-agent Studio). */ +export default function StudioIndexPage() { + redirect("/studio/train"); +} diff --git a/apps/dashboard/app/(dashboard)/studio/settings/page.tsx b/apps/dashboard/app/(dashboard)/studio/settings/page.tsx new file mode 100644 index 0000000..68d9c53 --- /dev/null +++ b/apps/dashboard/app/(dashboard)/studio/settings/page.tsx @@ -0,0 +1,32 @@ +import { notFound } from "next/navigation"; +import { PageHeader } from "../../../../components/page-header"; +import { ProductSettingsForm } from "../../../../features/products/product-settings-form"; +import { getOrCreateProduct } from "../../../../features/products/queries"; + +export const dynamic = "force-dynamic"; + +export default async function StudioSettingsPage() { + const product = await getOrCreateProduct(); + if (!product) notFound(); + + return ( +
+ + Live + + ) : ( + + Draft + + ) + } + /> + +
+ ); +} diff --git a/apps/dashboard/app/(dashboard)/studio/test/page.tsx b/apps/dashboard/app/(dashboard)/studio/test/page.tsx new file mode 100644 index 0000000..ab9050b --- /dev/null +++ b/apps/dashboard/app/(dashboard)/studio/test/page.tsx @@ -0,0 +1,21 @@ +import { notFound } from "next/navigation"; +import { PageHeader } from "../../../../components/page-header"; +import { TestPreviewPanel } from "../../../../features/products/test-preview-panel"; +import { getOrCreateProduct } from "../../../../features/products/queries"; + +export const dynamic = "force-dynamic"; + +export default async function StudioTestPage() { + const product = await getOrCreateProduct(); + if (!product) notFound(); + + return ( +
+ + +
+ ); +} diff --git a/apps/dashboard/app/(dashboard)/studio/train/page.tsx b/apps/dashboard/app/(dashboard)/studio/train/page.tsx new file mode 100644 index 0000000..fec7c95 --- /dev/null +++ b/apps/dashboard/app/(dashboard)/studio/train/page.tsx @@ -0,0 +1,100 @@ +import { Suspense } from "react"; +import { notFound } from "next/navigation"; +import { PageHeader } from "../../../../components/page-header"; +import { AddContentForms } from "../../../../features/products/add-content-forms"; +import { ContentList } from "../../../../features/products/content-list"; +import { TrainActionsPanel } from "../../../../features/products/train-actions-panel"; +import { TestPreviewPanel } from "../../../../features/products/test-preview-panel"; +import { + getOrCreateProduct, + listActions, + listContent, +} from "../../../../features/products/queries"; + +export const dynamic = "force-dynamic"; + +/** Placeholder rows shown while a list streams in. */ +function ListSkeleton() { + return ( +
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+ ); +} + +/** The "Your content" list, fetched on its own so it streams in after the shell. */ +async function ContentListSection({ productId }: { productId: string }) { + const items = await listContent(productId); + return ( +
+
+

Your content

+ + {items.length} {items.length === 1 ? "source" : "sources"} + +
+ +
+ ); +} + +/** The actions panel, fetched on its own so it streams in after the shell. */ +async function ActionsSection({ productId }: { productId: string }) { + const items = await listActions(productId); + return ; +} + +export default async function StudioTrainPage() { + // Resolve the product once (needed for the preview + status). The content and + // actions lists stream in via Suspense, so the shell + preview paint right + // away instead of blocking on every DB round-trip up front. + const product = await getOrCreateProduct(); + if (!product) notFound(); + + return ( +
+ + Live + + ) : ( + + Draft + + ) + } + /> + +
+
+
+
+

Add content

+

+ Sources your agent can learn from. Enabled items are used in chat. +

+
+ + }> + + +
+ + }> + + +
+ + +
+
+ ); +} diff --git a/apps/dashboard/app/api/widget/[publicKey]/actions/run/route.ts b/apps/dashboard/app/api/widget/[publicKey]/actions/run/route.ts new file mode 100644 index 0000000..b349aef --- /dev/null +++ b/apps/dashboard/app/api/widget/[publicKey]/actions/run/route.ts @@ -0,0 +1,106 @@ +import { getProductByPublicKey } from "../../../../../../features/products/queries"; +import { + getEnabledActionForPublicKey, + runProductAction, +} from "../../../../../../features/products/run-product-action"; +import { createRateLimiter } from "../../../../../../features/products/widget-chat"; + +// Public confirm-gated action runner for the widget. Chat only proposes; this +// endpoint runs after the visitor (or Test preview) confirms. +export const runtime = "nodejs"; +export const maxDuration = 60; + +const allowRequest = createRateLimiter(20, 60_000); + +const CORS_HEADERS: Record = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Max-Age": "86400", +}; + +interface RunBody { + actionId?: string; + /** Card that pays for capability runs (authenticated owner / Test). */ + agentId?: string; + params?: string | Record; +} + +function withCors(response: Response): Response { + const headers = new Headers(response.headers); + for (const [key, value] of Object.entries(CORS_HEADERS)) { + headers.set(key, value); + } + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +function json(data: unknown, status = 200): Response { + return withCors( + new Response(JSON.stringify(data), { + status, + headers: { + "content-type": "application/json", + "cache-control": "no-store", + }, + }), + ); +} + +export async function OPTIONS() { + return withCors(new Response(null, { status: 204 })); +} + +export async function POST(request: Request, context: { params: Promise<{ publicKey: string }> }) { + const { publicKey: rawKey } = await context.params; + const publicKey = decodeURIComponent(rawKey ?? "").trim(); + if (!publicKey) return json({ error: "Missing public key." }, 400); + + if (!allowRequest(publicKey)) { + return json({ error: "Too many requests. Try again in a minute." }, 429); + } + + const product = await getProductByPublicKey(publicKey); + if (!product) return json({ error: "Agent not found." }, 404); + + const preview = new URL(request.url).searchParams.get("preview") === "1"; + if (product.status !== "live" && !preview) { + return json({ error: "Agent not found." }, 404); + } + + const body = (await request.json().catch(() => null)) as RunBody | null; + const actionId = body?.actionId?.trim(); + if (!actionId) return json({ error: "actionId is required." }, 400); + + const owned = await getEnabledActionForPublicKey(publicKey, actionId); + if (!owned.ok) return json({ error: owned.error }, 404); + + const result = await runProductAction({ + actionId: owned.actionId, + agentId: body?.agentId, + params: body?.params, + }); + + if (!result.ok) { + return json( + { + ok: false, + error: result.error ?? "Could not run the action.", + status: result.status, + body: result.body, + }, + result.error === "Not signed in." || result.error === "Not allowed." ? 401 : 400, + ); + } + + return json({ + ok: true, + status: result.status, + body: result.body, + paid: result.paid, + txHash: result.txHash, + }); +} diff --git a/apps/dashboard/app/api/widget/[publicKey]/chat/route.ts b/apps/dashboard/app/api/widget/[publicKey]/chat/route.ts new file mode 100644 index 0000000..8a4f961 --- /dev/null +++ b/apps/dashboard/app/api/widget/[publicKey]/chat/route.ts @@ -0,0 +1,215 @@ +import { + getProductActionsForChat, + getProductByPublicKey, + getProductContentForChat, +} from "../../../../../features/products/queries"; +import { + actionIdFromToolName, + buildWidgetSystemPrompt, + buildWidgetTools, + createRateLimiter, + proposeWidgetAction, + type ProposedWidgetAction, +} from "../../../../../features/products/widget-chat"; + +// Public per-product widget chat. Answers from that product's enabled content +// and can PROPOSE enabled actions (confirm-gated; never runs them here). +// No auth: the publicKey is the Stripe-style publishable key. Node runtime for +// the OpenRouter key; room for a short tool loop. +export const runtime = "nodejs"; +export const maxDuration = 60; + +const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"; +const MODEL = process.env.OPENROUTER_MODEL ?? "google/gemini-2.5-flash"; +const MAX_TOKENS = 700; +const MAX_MESSAGES = 20; +const MAX_TOOL_HOPS = 3; + +const allowRequest = createRateLimiter(20, 60_000); + +const CORS_HEADERS: Record = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Max-Age": "86400", +}; + +interface ToolCall { + id: string; + function: { name: string; arguments: string }; +} + +interface ChatMessage { + role: string; + content: string | null; + tool_calls?: ToolCall[]; + tool_call_id?: string; + name?: string; +} + +interface OpenRouterResponse { + choices?: { message?: ChatMessage }[]; +} + +interface WidgetChatBody { + messages?: { role: string; content: string }[]; +} + +function withCors(response: Response): Response { + const headers = new Headers(response.headers); + for (const [key, value] of Object.entries(CORS_HEADERS)) { + headers.set(key, value); + } + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +function json(data: unknown, status = 200): Response { + return withCors( + new Response(JSON.stringify(data), { + status, + headers: { + "content-type": "application/json", + "cache-control": "no-store", + }, + }), + ); +} + +function reply(text: string, action: ProposedWidgetAction | null = null): Response { + return json({ reply: text, action }); +} + +function safeParseArgs(raw: string | undefined): Record { + if (!raw) return {}; + try { + const v: unknown = JSON.parse(raw); + return v && typeof v === "object" && !Array.isArray(v) ? (v as Record) : {}; + } catch { + return {}; + } +} + +export async function OPTIONS() { + return withCors(new Response(null, { status: 204 })); +} + +export async function POST(request: Request, context: { params: Promise<{ publicKey: string }> }) { + const { publicKey: rawKey } = await context.params; + const publicKey = decodeURIComponent(rawKey ?? "").trim(); + if (!publicKey) return json({ error: "Missing public key." }, 400); + + if (!allowRequest(publicKey)) { + return json({ error: "Too many requests. Try again in a minute." }, 429); + } + + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) { + return json({ error: "The agent is not configured yet." }, 503); + } + + const product = await getProductByPublicKey(publicKey); + if (!product) return json({ error: "Agent not found." }, 404); + + const preview = new URL(request.url).searchParams.get("preview") === "1"; + if (product.status !== "live" && !preview) { + return json({ error: "Agent not found." }, 404); + } + + const body = (await request.json().catch(() => null)) as WidgetChatBody | null; + const messages = body?.messages; + if (!Array.isArray(messages) || messages.length === 0) { + return json({ error: "messages is required." }, 400); + } + + const cleaned: { role: "user" | "assistant"; content: string }[] = []; + for (const m of messages) { + if (!m || (m.role !== "user" && m.role !== "assistant")) continue; + if (typeof m.content !== "string" || !m.content.trim()) continue; + cleaned.push({ role: m.role, content: m.content.trim().slice(0, 4000) }); + } + if (cleaned.length === 0 || cleaned[cleaned.length - 1]?.role !== "user") { + return json({ error: "The last message must be from the user." }, 400); + } + + const [content, actions] = await Promise.all([ + getProductContentForChat(product.id), + getProductActionsForChat(product.id), + ]); + const system = buildWidgetSystemPrompt(product, content, actions); + const tools = buildWidgetTools(actions); + const actionsById = new Map(actions.map((a) => [a.id, a])); + + const convo: ChatMessage[] = [ + { role: "system", content: system }, + ...cleaned.slice(-MAX_MESSAGES), + ]; + + try { + for (let hop = 0; hop < MAX_TOOL_HOPS; hop += 1) { + const resp = await fetch(OPENROUTER_URL, { + method: "POST", + headers: { + authorization: `Bearer ${apiKey}`, + "content-type": "application/json", + "HTTP-Referer": "https://taelprotocol.xyz", + "X-Title": "Tael Widget Agent", + }, + body: JSON.stringify({ + model: MODEL, + messages: convo, + ...(tools.length > 0 ? { tools, tool_choice: "auto" } : {}), + max_tokens: MAX_TOKENS, + temperature: 0.3, + }), + }); + + if (!resp.ok) { + return json({ error: "The agent is unavailable right now. Please try again." }, 502); + } + + const data = (await resp.json()) as OpenRouterResponse; + const message = data.choices?.[0]?.message; + if (!message) { + return json({ error: "No response from the agent. Please try again." }, 502); + } + + if (message.tool_calls?.length) { + // A write proposal is terminal: resolve the first matching action tool + // and return a confirm card. Do not execute server-side. + for (const call of message.tool_calls) { + const actionId = actionIdFromToolName(call.function.name); + if (!actionId) continue; + const action = actionsById.get(actionId); + if (!action) continue; + const proposed = proposeWidgetAction(action, safeParseArgs(call.function.arguments)); + return reply(proposed.reply, proposed.action); + } + // Unknown tools: tell the model and continue (should be rare). + convo.push(message); + for (const call of message.tool_calls) { + convo.push({ + role: "tool", + tool_call_id: call.id, + name: call.function.name, + content: JSON.stringify({ error: "unknown or disabled action" }), + }); + } + continue; + } + + const text = typeof message.content === "string" ? message.content.trim() : ""; + if (!text) { + return json({ error: "No response from the agent. Please try again." }, 502); + } + return reply(text, null); + } + + return reply("I couldn't quite finish that. Try rephrasing?"); + } catch { + return json({ error: "Something went wrong. Please try again." }, 502); + } +} diff --git a/apps/dashboard/app/api/widget/[publicKey]/config/route.ts b/apps/dashboard/app/api/widget/[publicKey]/config/route.ts new file mode 100644 index 0000000..ed51fe5 --- /dev/null +++ b/apps/dashboard/app/api/widget/[publicKey]/config/route.ts @@ -0,0 +1,68 @@ +import { getProductByPublicKey } from "../../../../../features/products/queries"; +import { createRateLimiter } from "../../../../../features/products/widget-chat"; + +// Public branding config for the embed widget. Returns only safe display fields +// (no ownerId or other private data). +export const runtime = "nodejs"; + +const allowRequest = createRateLimiter(60, 60_000); + +const CORS_HEADERS: Record = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Max-Age": "86400", +}; + +function withCors(response: Response): Response { + const headers = new Headers(response.headers); + for (const [key, value] of Object.entries(CORS_HEADERS)) { + headers.set(key, value); + } + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +function json(data: unknown, status = 200): Response { + return withCors( + new Response(JSON.stringify(data), { + status, + headers: { + "content-type": "application/json", + "cache-control": "public, max-age=60", + }, + }), + ); +} + +export async function OPTIONS() { + return withCors(new Response(null, { status: 204 })); +} + +export async function GET(request: Request, context: { params: Promise<{ publicKey: string }> }) { + const { publicKey: rawKey } = await context.params; + const publicKey = decodeURIComponent(rawKey ?? "").trim(); + if (!publicKey) return json({ error: "Missing public key." }, 400); + + if (!allowRequest(publicKey)) { + return json({ error: "Too many requests. Try again in a minute." }, 429); + } + + const product = await getProductByPublicKey(publicKey); + if (!product) return json({ error: "Agent not found." }, 404); + + const preview = new URL(request.url).searchParams.get("preview") === "1"; + if (product.status !== "live" && !preview) { + return json({ error: "Agent not found." }, 404); + } + + return json({ + name: product.name, + greeting: product.greeting, + brandColor: product.brandColor, + logoUrl: product.logoUrl, + }); +} diff --git a/apps/dashboard/features/navigation/nav.config.ts b/apps/dashboard/features/navigation/nav.config.ts index 60820a9..cf8281e 100644 --- a/apps/dashboard/features/navigation/nav.config.ts +++ b/apps/dashboard/features/navigation/nav.config.ts @@ -1,12 +1,17 @@ import { BarChart3, + BookOpen, Boxes, Building2, CreditCard, + FlaskConical, + Inbox, KeyRound, LayoutDashboard, ArrowLeftRight, + Rocket, Settings, + SlidersHorizontal, Star, Store, Wallet, @@ -44,6 +49,16 @@ export const navGroups: NavGroup[] = [ { label: "Cards", href: "/agents", icon: CreditCard }, ], }, + { + label: "Studio", + items: [ + { label: "Train", href: "/studio/train", icon: BookOpen }, + { label: "Test", href: "/studio/test", icon: FlaskConical }, + { label: "Deploy", href: "/studio/deploy", icon: Rocket }, + { label: "Inbox", href: "/studio/inbox", icon: Inbox }, + { label: "Settings", href: "/studio/settings", icon: SlidersHorizontal }, + ], + }, { label: "Insights", items: [ diff --git a/apps/dashboard/features/products/action-actions.ts b/apps/dashboard/features/products/action-actions.ts new file mode 100644 index 0000000..5b11bd1 --- /dev/null +++ b/apps/dashboard/features/products/action-actions.ts @@ -0,0 +1,195 @@ +"use server"; + +import { z } from "zod"; +import { and, eq, productActions, products } from "@tael/database"; +import { db } from "../../lib/db"; +import { getCurrentUser } from "../capabilities/current-user"; +import { type ActionResult } from "./actions"; +import { revalidateStudioPaths } from "./revalidate"; + +const nameSchema = z.string().trim().min(1, "Name is required").max(80); +const descriptionSchema = z.string().trim().min(1, "Description is required").max(1000); +const httpMethodSchema = z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]); + +const capabilityConfigSchema = z.object({ + slug: z.string().trim().min(1, "Capability slug is required").max(120), +}); + +const httpConfigSchema = z.object({ + url: z.string().trim().url("Enter a valid URL").max(2000), + method: httpMethodSchema, + paramsSchema: z.record(z.string(), z.unknown()).optional(), +}); + +const addActionSchema = z.discriminatedUnion("kind", [ + z.object({ + name: nameSchema, + description: descriptionSchema, + kind: z.literal("capability"), + config: capabilityConfigSchema, + shareAsCapability: z.boolean().optional(), + }), + z.object({ + name: nameSchema, + description: descriptionSchema, + kind: z.literal("http"), + config: httpConfigSchema, + shareAsCapability: z.boolean().optional(), + }), +]); + +const updateActionSchema = z.object({ + name: nameSchema.optional(), + description: descriptionSchema.optional(), + shareAsCapability: z.boolean().optional(), + config: z.union([capabilityConfigSchema, httpConfigSchema]).optional(), +}); + +async function assertProductOwner( + productId: string, + userId: string, +): Promise<{ ok: true } | { ok: false; error: string }> { + const [row] = await db + .select({ id: products.id }) + .from(products) + .where(and(eq(products.id, productId), eq(products.ownerId, userId))) + .limit(1); + if (!row) return { ok: false, error: "Agent not found." }; + return { ok: true }; +} + +async function assertActionOwner( + actionId: string, + userId: string, +): Promise<{ ok: true; productId: string } | { ok: false; error: string }> { + const [row] = await db + .select({ id: productActions.id, productId: productActions.productId }) + .from(productActions) + .innerJoin(products, eq(productActions.productId, products.id)) + .where(and(eq(productActions.id, actionId), eq(products.ownerId, userId))) + .limit(1); + if (!row) return { ok: false, error: "Action not found." }; + return { ok: true, productId: row.productId }; +} + +export type AddActionInput = z.infer; + +/** Insert an action for a product the signed-in user owns. */ +export async function addAction(productId: string, input: AddActionInput): Promise { + const user = await getCurrentUser(); + if (!user) return { ok: false, error: "Not signed in." }; + + const parsed = addActionSchema.safeParse(input); + if (!parsed.success) { + return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input." }; + } + + const owned = await assertProductOwner(productId, user.id); + if (!owned.ok) return owned; + + try { + const [row] = await db + .insert(productActions) + .values({ + productId, + name: parsed.data.name, + description: parsed.data.description, + kind: parsed.data.kind, + config: parsed.data.config, + shareAsCapability: parsed.data.shareAsCapability ?? false, + }) + .returning({ id: productActions.id }); + + revalidateStudioPaths(); + return { ok: true, id: row!.id }; + } catch { + return { ok: false, error: "Could not add action. Try again." }; + } +} + +/** Update an action. Ownership-checked via product join. */ +export async function updateAction( + actionId: string, + input: z.infer, +): Promise { + const user = await getCurrentUser(); + if (!user) return { ok: false, error: "Not signed in." }; + + const parsed = updateActionSchema.safeParse(input); + if (!parsed.success) { + return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input." }; + } + + const owned = await assertActionOwner(actionId, user.id); + if (!owned.ok) return owned; + + const patch = parsed.data; + if (Object.keys(patch).length === 0) { + return { ok: false, error: "Nothing to update." }; + } + + // If config is patched, keep it aligned with the row's kind. + if (patch.config) { + const [existing] = await db + .select({ kind: productActions.kind }) + .from(productActions) + .where(eq(productActions.id, actionId)) + .limit(1); + if (!existing) return { ok: false, error: "Action not found." }; + if (existing.kind === "capability") { + const cfg = capabilityConfigSchema.safeParse(patch.config); + if (!cfg.success) { + return { ok: false, error: cfg.error.issues[0]?.message ?? "Invalid config." }; + } + patch.config = cfg.data; + } else { + const cfg = httpConfigSchema.safeParse(patch.config); + if (!cfg.success) { + return { ok: false, error: cfg.error.issues[0]?.message ?? "Invalid config." }; + } + patch.config = cfg.data; + } + } + + try { + await db.update(productActions).set(patch).where(eq(productActions.id, actionId)); + revalidateStudioPaths(); + return { ok: true, id: actionId }; + } catch { + return { ok: false, error: "Could not save. Try again." }; + } +} + +/** Delete an action. Ownership-checked via product join. */ +export async function deleteAction(actionId: string): Promise { + const user = await getCurrentUser(); + if (!user) return { ok: false, error: "Not signed in." }; + + const owned = await assertActionOwner(actionId, user.id); + if (!owned.ok) return owned; + + try { + await db.delete(productActions).where(eq(productActions.id, actionId)); + revalidateStudioPaths(); + return { ok: true }; + } catch { + return { ok: false, error: "Could not delete. Try again." }; + } +} + +/** Enable or disable an action. Ownership-checked via product join. */ +export async function toggleAction(actionId: string, enabled: boolean): Promise { + const user = await getCurrentUser(); + if (!user) return { ok: false, error: "Not signed in." }; + + const owned = await assertActionOwner(actionId, user.id); + if (!owned.ok) return owned; + + try { + await db.update(productActions).set({ enabled }).where(eq(productActions.id, actionId)); + revalidateStudioPaths(); + return { ok: true, id: actionId }; + } catch { + return { ok: false, error: "Could not update. Try again." }; + } +} diff --git a/apps/dashboard/features/products/action-dialogs.tsx b/apps/dashboard/features/products/action-dialogs.tsx new file mode 100644 index 0000000..b8edbc5 --- /dev/null +++ b/apps/dashboard/features/products/action-dialogs.tsx @@ -0,0 +1,213 @@ +"use client"; + +import { useState, useTransition, type ReactNode } from "react"; +import { useRouter } from "next/navigation"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + Input, + Textarea, +} from "@tael/ui"; +import { addAction, type AddActionInput } from "./action-actions"; + +function Field({ label, children }: { label: string; children: ReactNode }) { + return ( + + ); +} + +type Kind = "capability" | "http"; + +/** Dialog to register a capability or HTTP action for the product agent. */ +export function ConnectActionDialog({ + open, + onClose, + productId, +}: { + open: boolean; + onClose: () => void; + productId: string; +}) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [kind, setKind] = useState("capability"); + const [slug, setSlug] = useState(""); + const [url, setUrl] = useState(""); + const [method, setMethod] = useState<"GET" | "POST" | "PUT" | "PATCH" | "DELETE">("POST"); + const [shareAsCapability, setShareAsCapability] = useState(false); + const [error, setError] = useState(null); + + function reset() { + setName(""); + setDescription(""); + setKind("capability"); + setSlug(""); + setUrl(""); + setMethod("POST"); + setShareAsCapability(false); + setError(null); + } + + function submit() { + setError(null); + startTransition(async () => { + const input: AddActionInput = + kind === "capability" + ? { + name, + description, + kind: "capability", + config: { slug }, + shareAsCapability, + } + : { + name, + description, + kind: "http", + config: { url, method }, + shareAsCapability, + }; + + const res = await addAction(productId, input); + if (res.ok) { + reset(); + onClose(); + router.refresh(); + } else { + setError(res.error ?? "Could not add action."); + } + }); + } + + const canSubmit = + name.trim().length > 0 && + description.trim().length > 0 && + (kind === "capability" ? slug.trim().length > 0 : url.trim().length > 0); + + return ( + { + if (!o) { + reset(); + onClose(); + } + }} + > + + + Connect action + + Register a capability or HTTP endpoint this agent can propose to visitors. + + +
+ + setName(e.target.value)} + placeholder="e.g. Book a demo" + autoFocus + /> + + + ', + ' ", + "
", + "
", + '", + ].join(""); + root.appendChild(wrap); + + var panel = wrap.querySelector(".panel"); + var launcher = wrap.querySelector(".launcher"); + var closeBtn = wrap.querySelector(".close"); + var msgs = wrap.querySelector(".msgs"); + var form = wrap.querySelector(".composer"); + var input = wrap.querySelector("textarea"); + var sendBtn = wrap.querySelector(".composer button"); + var titleEl = wrap.querySelector(".title h1"); + var logoEl = wrap.querySelector(".logo"); + + var open = false; + var busy = false; + var history = []; + var greeted = false; + + function setBrand(color) { + brand = color || "#156DFC"; + launcher.style.background = brand; + sendBtn.style.background = brand; + wrap.style.setProperty("--tael-brand", brand); + if (logoEl.classList.contains("fallback")) { + logoEl.style.background = brand; + } + } + + function setOpen(next) { + open = next; + panel.classList.toggle("open", open); + launcher.setAttribute("aria-expanded", open ? "true" : "false"); + launcher.setAttribute("aria-label", open ? "Close chat" : "Open chat"); + if (open) { + if (!greeted && greeting) { + appendAssistant(greeting, null); + greeted = true; + } + input.focus(); + scrollBottom(); + } + } + + function scrollBottom() { + msgs.scrollTop = msgs.scrollHeight; + } + + function escapeHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function formatText(s) { + var escaped = escapeHtml(s); + return escaped.replace(/\*\*([^*]+)\*\*/g, "$1").replace(/\n/g, "
"); + } + + function appendBubble(role, html) { + var el = document.createElement("div"); + el.className = "bubble " + role; + el.innerHTML = html; + msgs.appendChild(el); + scrollBottom(); + return el; + } + + function appendAssistant(text, action) { + appendBubble("assistant", formatText(text || "")); + if (action) appendConfirm(action); + } + + function appendConfirm(action) { + var card = document.createElement("div"); + card.className = "confirm"; + var label = document.createElement("p"); + label.className = "label"; + label.textContent = action.kind === "http" ? "Run action" : "Run capability"; + var name = document.createElement("p"); + name.className = "name"; + name.textContent = action.name || "Action"; + var btn = document.createElement("button"); + btn.type = "button"; + btn.textContent = "Confirm"; + btn.addEventListener("click", function () { + runAction(action, card, btn); + }); + card.appendChild(label); + card.appendChild(name); + card.appendChild(btn); + msgs.appendChild(card); + scrollBottom(); + } + + function setBusy(next) { + busy = next; + sendBtn.disabled = busy || !input.value.trim(); + input.disabled = busy; + } + + function showTyping() { + var el = document.createElement("div"); + el.className = "bubble assistant"; + el.innerHTML = ''; + msgs.appendChild(el); + scrollBottom(); + return el; + } + + function applyConfig(cfg) { + if (!cfg) return; + agentName = cfg.name || agentName; + greeting = (cfg.greeting || "").trim(); + logoUrl = cfg.logoUrl || null; + titleEl.textContent = agentName; + setBrand(cfg.brandColor); + if (logoUrl) { + var img = document.createElement("img"); + img.className = "logo"; + img.alt = ""; + img.src = logoUrl; + logoEl.replaceWith(img); + logoEl = img; + } else { + logoEl.textContent = (agentName.charAt(0) || "T").toUpperCase(); + logoEl.style.background = brand; + } + } + + function loadConfig() { + return fetch(apiRoot + "/config", { method: "GET", credentials: "omit" }) + .then(function (res) { + return res.json().then(function (data) { + if (!res.ok) throw new Error((data && data.error) || "Config failed"); + return data; + }); + }) + .then(applyConfig) + .catch(function () { + titleEl.textContent = agentName; + setBrand(brand); + logoEl.textContent = "T"; + logoEl.style.background = brand; + }); + } + + function sendMessage(text) { + var content = (text || "").trim(); + if (!content || busy) return; + appendBubble("user", escapeHtml(content)); + history.push({ role: "user", content: content }); + input.value = ""; + input.style.height = "auto"; + setBusy(true); + var typing = showTyping(); + + fetch(apiRoot + "/chat", { + method: "POST", + credentials: "omit", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ messages: history.slice(-20) }), + }) + .then(function (res) { + return res.json().then(function (data) { + return { res: res, data: data }; + }); + }) + .then(function (out) { + typing.remove(); + if (!out.res.ok) { + appendBubble( + "assistant", + escapeHtml((out.data && out.data.error) || "Something went wrong. Please try again."), + ); + return; + } + var reply = (out.data && out.data.reply) || ""; + var action = out.data && out.data.action ? out.data.action : null; + if (reply) history.push({ role: "assistant", content: reply }); + appendAssistant(reply || "…", action); + }) + .catch(function () { + typing.remove(); + appendBubble("assistant", "Could not reach the agent. Please try again."); + }) + .then(function () { + setBusy(false); + input.focus(); + }); + } + + function runAction(action, card, btn) { + if (busy) return; + btn.disabled = true; + btn.textContent = "Running…"; + setBusy(true); + + var body = { actionId: action.actionId }; + if (action.kind === "http" && action.params) body.params = action.params; + if (action.kind === "capability" && action.params) body.params = action.params; + + fetch(apiRoot + "/actions/run", { + method: "POST", + credentials: "omit", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) + .then(function (res) { + return res.json().then(function (data) { + return { res: res, data: data }; + }); + }) + .then(function (out) { + card.remove(); + if (out.res.status === 401 || (out.data && out.data.error === "Not signed in.")) { + appendBubble( + "assistant", + "This action needs the site owner to run it. It is not available to visitors.", + ); + return; + } + if (!out.res.ok || !(out.data && out.data.ok)) { + appendBubble( + "assistant", + escapeHtml( + (out.data && out.data.error) || "Could not run that action. Please try again.", + ), + ); + return; + } + var msg = "Ran **" + (action.name || "action") + "**."; + if (out.data.body) { + var clipped = + String(out.data.body).length > 400 + ? String(out.data.body).slice(0, 400) + "…" + : String(out.data.body); + msg += "\n\n" + clipped; + } + appendAssistant(msg, null); + }) + .catch(function () { + btn.disabled = false; + btn.textContent = "Confirm"; + appendBubble("assistant", "Could not reach the server. Please try again."); + }) + .then(function () { + setBusy(false); + }); + } + + launcher.addEventListener("click", function () { + setOpen(!open); + }); + closeBtn.addEventListener("click", function () { + setOpen(false); + }); + + form.addEventListener("submit", function (e) { + e.preventDefault(); + sendMessage(input.value); + }); + + input.addEventListener("input", function () { + sendBtn.disabled = busy || !input.value.trim(); + input.style.height = "auto"; + input.style.height = Math.min(input.scrollHeight, 96) + "px"; + }); + + input.addEventListener("keydown", function (e) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendMessage(input.value); + } + }); + + setBrand(brand); + titleEl.textContent = agentName; + loadConfig(); +})(); diff --git a/packages/database/drizzle/0016_common_captain_cross.sql b/packages/database/drizzle/0016_common_captain_cross.sql new file mode 100644 index 0000000..d92d21a --- /dev/null +++ b/packages/database/drizzle/0016_common_captain_cross.sql @@ -0,0 +1,52 @@ +CREATE TYPE "public"."product_action_kind" AS ENUM('capability', 'http');--> statement-breakpoint +CREATE TYPE "public"."product_content_type" AS ENUM('doc', 'snippet', 'website', 'faq');--> statement-breakpoint +CREATE TYPE "public"."product_status" AS ENUM('draft', 'live');--> statement-breakpoint +CREATE TABLE "product_actions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "product_id" uuid NOT NULL, + "name" text NOT NULL, + "description" text NOT NULL, + "kind" "product_action_kind" NOT NULL, + "config" jsonb NOT NULL, + "share_as_capability" boolean DEFAULT false NOT NULL, + "enabled" boolean DEFAULT true 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 "product_content" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "product_id" uuid NOT NULL, + "type" "product_content_type" NOT NULL, + "title" text NOT NULL, + "body" text NOT NULL, + "source_url" text, + "enabled" boolean DEFAULT true 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 "products" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "owner_id" uuid NOT NULL, + "slug" text NOT NULL, + "name" text NOT NULL, + "description" text DEFAULT '' NOT NULL, + "logo_url" text, + "brand_color" text DEFAULT '#156DFC' NOT NULL, + "greeting" text DEFAULT '' NOT NULL, + "public_key" text NOT NULL, + "status" "product_status" DEFAULT 'draft' NOT NULL, + "settings" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "products_slug_unique" UNIQUE("slug"), + CONSTRAINT "products_public_key_unique" UNIQUE("public_key") +); +--> statement-breakpoint +ALTER TABLE "product_actions" ADD CONSTRAINT "product_actions_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "product_content" ADD CONSTRAINT "product_content_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "products" ADD CONSTRAINT "products_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "product_actions_product_id_idx" ON "product_actions" USING btree ("product_id");--> statement-breakpoint +CREATE INDEX "product_content_product_id_idx" ON "product_content" USING btree ("product_id");--> statement-breakpoint +CREATE INDEX "products_owner_id_idx" ON "products" USING btree ("owner_id"); \ No newline at end of file diff --git a/packages/database/drizzle/meta/0016_snapshot.json b/packages/database/drizzle/meta/0016_snapshot.json new file mode 100644 index 0000000..ac18a38 --- /dev/null +++ b/packages/database/drizzle/meta/0016_snapshot.json @@ -0,0 +1,1563 @@ +{ + "id": "8b20e51b-87f7-4f26-9fd4-0fe3f7f4b7f3", + "prevId": "dc195d49-ccd7-43b7-b5f4-d250982fe045", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "wallet_address": { + "name": "wallet_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "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": { + "users_wallet_address_idx": { + "name": "users_wallet_address_idx", + "columns": [ + { + "expression": "wallet_address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_wallet_address_unique": { + "name": "users_wallet_address_unique", + "nullsNotDistinct": false, + "columns": [ + "wallet_address" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallets": { + "name": "wallets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Main'" + }, + "secret_enc": { + "name": "secret_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "balance_cached": { + "name": "balance_cached", + "type": "numeric(20, 7)", + "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": { + "wallets_owner_id_idx": { + "name": "wallets_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallets_owner_id_users_id_fk": { + "name": "wallets_owner_id_users_id_fk", + "tableFrom": "wallets", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wallets_address_unique": { + "name": "wallets_address_unique", + "nullsNotDistinct": false, + "columns": [ + "address" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.capabilities": { + "name": "capabilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contact": { + "name": "contact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "capability_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "capability_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "status": { + "name": "status", + "type": "capability_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "faqs": { + "name": "faqs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "price": { + "name": "price", + "type": "numeric(20, 7)", + "primaryKey": false, + "notNull": true + }, + "pay_to": { + "name": "pay_to", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "upstream_url": { + "name": "upstream_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "upstream_secret_enc": { + "name": "upstream_secret_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "upstream_auth": { + "name": "upstream_auth", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "billing": { + "name": "billing", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "publisher_id": { + "name": "publisher_id", + "type": "uuid", + "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": { + "capabilities_publisher_id_idx": { + "name": "capabilities_publisher_id_idx", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "capabilities_kind_idx": { + "name": "capabilities_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "capabilities_visibility_idx": { + "name": "capabilities_visibility_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "capabilities_publisher_id_users_id_fk": { + "name": "capabilities_publisher_id_users_id_fk", + "tableFrom": "capabilities", + "tableTo": "users", + "columnsFrom": [ + "publisher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "capabilities_slug_unique": { + "name": "capabilities_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wallet_id": { + "name": "wallet_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "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": { + "agents_owner_id_idx": { + "name": "agents_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_owner_id_users_id_fk": { + "name": "agents_owner_id_users_id_fk", + "tableFrom": "agents", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agents_wallet_id_wallets_id_fk": { + "name": "agents_wallet_id_wallets_id_fk", + "tableFrom": "agents", + "tableTo": "wallets", + "columnsFrom": [ + "wallet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "capability_id": { + "name": "capability_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "capability_name": { + "name": "capability_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "payer": { + "name": "payer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payee": { + "name": "payee", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(20, 7)", + "primaryKey": false, + "notNull": true + }, + "fee": { + "name": "fee", + "type": "numeric(20, 7)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "status": { + "name": "status", + "type": "payment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "tx_hash": { + "name": "tx_hash", + "type": "text", + "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": { + "payments_capability_id_idx": { + "name": "payments_capability_id_idx", + "columns": [ + { + "expression": "capability_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_agent_id_idx": { + "name": "payments_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_payer_idx": { + "name": "payments_payer_idx", + "columns": [ + { + "expression": "payer", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_payee_idx": { + "name": "payments_payee_idx", + "columns": [ + { + "expression": "payee", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_status_idx": { + "name": "payments_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_tx_hash_unique": { + "name": "payments_tx_hash_unique", + "columns": [ + { + "expression": "tx_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payments_capability_id_capabilities_id_fk": { + "name": "payments_capability_id_capabilities_id_fk", + "tableFrom": "payments", + "tableTo": "capabilities", + "columnsFrom": [ + "capability_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "payments_agent_id_agents_id_fk": { + "name": "payments_agent_id_agents_id_fk", + "tableFrom": "payments", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Default'" + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_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": { + "api_keys_owner_id_idx": { + "name": "api_keys_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_key_hash_idx": { + "name": "api_keys_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_owner_id_users_id_fk": { + "name": "api_keys_owner_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_keys_agent_id_agents_id_fk": { + "name": "api_keys_agent_id_agents_id_fk", + "tableFrom": "api_keys", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "key_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reviews": { + "name": "reviews", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "capability_id": { + "name": "capability_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reviewer_id": { + "name": "reviewer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "comment": { + "name": "comment", + "type": "text", + "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": { + "reviews_capability_id_idx": { + "name": "reviews_capability_id_idx", + "columns": [ + { + "expression": "capability_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reviews_capability_id_capabilities_id_fk": { + "name": "reviews_capability_id_capabilities_id_fk", + "tableFrom": "reviews", + "tableTo": "capabilities", + "columnsFrom": [ + "capability_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reviews_reviewer_id_users_id_fk": { + "name": "reviews_reviewer_id_users_id_fk", + "tableFrom": "reviews", + "tableTo": "users", + "columnsFrom": [ + "reviewer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "reviews_capability_reviewer_unique": { + "name": "reviews_capability_reviewer_unique", + "nullsNotDistinct": false, + "columns": [ + "capability_id", + "reviewer_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product_actions": { + "name": "product_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "product_action_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "share_as_capability": { + "name": "share_as_capability", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "product_actions_product_id_idx": { + "name": "product_actions_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "product_actions_product_id_products_id_fk": { + "name": "product_actions_product_id_products_id_fk", + "tableFrom": "product_actions", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product_content": { + "name": "product_content", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "product_content_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "product_content_product_id_idx": { + "name": "product_content_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "product_content_product_id_products_id_fk": { + "name": "product_content_product_id_products_id_fk", + "tableFrom": "product_content", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "brand_color": { + "name": "brand_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#156DFC'" + }, + "greeting": { + "name": "greeting", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "product_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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": { + "products_owner_id_idx": { + "name": "products_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "products_owner_id_users_id_fk": { + "name": "products_owner_id_users_id_fk", + "tableFrom": "products", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "products_slug_unique": { + "name": "products_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "products_public_key_unique": { + "name": "products_public_key_unique", + "nullsNotDistinct": false, + "columns": [ + "public_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_messages": { + "name": "chat_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "chat_message_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_messages_thread_id_idx": { + "name": "chat_messages_thread_id_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_messages_thread_id_chat_threads_id_fk": { + "name": "chat_messages_thread_id_chat_threads_id_fk", + "tableFrom": "chat_messages", + "tableTo": "chat_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_threads": { + "name": "chat_threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "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": { + "chat_threads_owner_id_idx": { + "name": "chat_threads_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_threads_owner_id_users_id_fk": { + "name": "chat_threads_owner_id_users_id_fk", + "tableFrom": "chat_threads", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.capability_kind": { + "name": "capability_kind", + "schema": "public", + "values": [ + "api", + "mcp", + "agent", + "model", + "dataset", + "credit" + ] + }, + "public.capability_status": { + "name": "capability_status", + "schema": "public", + "values": [ + "draft", + "pending", + "verified" + ] + }, + "public.capability_visibility": { + "name": "capability_visibility", + "schema": "public", + "values": [ + "public", + "unlisted", + "private" + ] + }, + "public.payment_status": { + "name": "payment_status", + "schema": "public", + "values": [ + "pending", + "settled", + "failed", + "refunded" + ] + }, + "public.product_action_kind": { + "name": "product_action_kind", + "schema": "public", + "values": [ + "capability", + "http" + ] + }, + "public.product_content_type": { + "name": "product_content_type", + "schema": "public", + "values": [ + "doc", + "snippet", + "website", + "faq" + ] + }, + "public.product_status": { + "name": "product_status", + "schema": "public", + "values": [ + "draft", + "live" + ] + }, + "public.chat_message_role": { + "name": "chat_message_role", + "schema": "public", + "values": [ + "user", + "assistant" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/database/drizzle/meta/_journal.json b/packages/database/drizzle/meta/_journal.json index d1fd5d2..a4e824a 100644 --- a/packages/database/drizzle/meta/_journal.json +++ b/packages/database/drizzle/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1784400000000, "tag": "0015_chat_threads_and_messages", "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1785490120134, + "tag": "0016_common_captain_cross", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/database/src/schema/index.ts b/packages/database/src/schema/index.ts index 0bd41a8..ed4cbbf 100644 --- a/packages/database/src/schema/index.ts +++ b/packages/database/src/schema/index.ts @@ -8,5 +8,6 @@ export * from "./agents"; export * from "./payments"; export * from "./api-keys"; export * from "./reviews"; +export * from "./products"; export * from "./relations"; export * from "./chat"; diff --git a/packages/database/src/schema/products.ts b/packages/database/src/schema/products.ts new file mode 100644 index 0000000..cfa83e6 --- /dev/null +++ b/packages/database/src/schema/products.ts @@ -0,0 +1,108 @@ +import { boolean, index, jsonb, pgEnum, pgTable, text, uuid } from "drizzle-orm/pg-core"; +import { primaryId, timestamps } from "./_shared"; +import { users } from "./users"; + +/** Lifecycle of a product agent: draft until the owner goes live. */ +export const productStatus = pgEnum("product_status", ["draft", "live"]); + +/** Kinds of content the agent can be trained on. */ +export const productContentType = pgEnum("product_content_type", [ + "doc", + "snippet", + "website", + "faq", +]); + +/** + * How a product action is invoked: + * - `capability` — a Tael capability slug + * - `http` — a direct HTTP call with url/method/paramsSchema + */ +export const productActionKind = pgEnum("product_action_kind", ["capability", "http"]); + +/** + * Config for a product action. Discriminated by `kind` on the row: + * - capability → `{ slug }` + * - http → `{ url, method, paramsSchema? }` + */ +export type ProductActionConfig = + { slug: string } | { url: string; method: string; paramsSchema?: Record }; + +/** + * One product tenant: an embeddable agent configured by a product owner. + * `publicKey` is the Stripe-style publishable key used by the embed snippet. + */ +export const products = pgTable( + "products", + { + id: primaryId(), + ownerId: uuid("owner_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + /** URL-safe unique handle. */ + slug: text("slug").notNull().unique(), + name: text("name").notNull(), + description: text("description").notNull().default(""), + /** Optional product logo URL for widget branding. */ + logoUrl: text("logo_url"), + /** Widget accent color. */ + brandColor: text("brand_color").notNull().default("#156DFC"), + /** First message the widget shows. */ + greeting: text("greeting").notNull().default(""), + /** Embed key, safe to expose (e.g. `tael_pub_…`). */ + publicKey: text("public_key").notNull().unique(), + status: productStatus("status").notNull().default("draft"), + /** Misc future config (channels, etc.). */ + settings: jsonb("settings").$type>().notNull().default({}), + ...timestamps, + }, + (table) => [index("products_owner_id_idx").on(table.ownerId)], +); + +/** Content the product agent knows (Train data). */ +export const productContent = pgTable( + "product_content", + { + id: primaryId(), + productId: uuid("product_id") + .notNull() + .references(() => products.id, { onDelete: "cascade" }), + type: productContentType("type").notNull(), + title: text("title").notNull(), + /** The text the agent uses at chat time. */ + body: text("body").notNull(), + /** Source URL for website sync. */ + sourceUrl: text("source_url"), + enabled: boolean("enabled").notNull().default(true), + ...timestamps, + }, + (table) => [index("product_content_product_id_idx").on(table.productId)], +); + +/** Actions the product agent can run (connected capabilities / HTTP). */ +export const productActions = pgTable( + "product_actions", + { + id: primaryId(), + productId: uuid("product_id") + .notNull() + .references(() => products.id, { onDelete: "cascade" }), + name: text("name").notNull(), + /** What it does and when the agent should use it. */ + description: text("description").notNull(), + kind: productActionKind("kind").notNull(), + config: jsonb("config").$type().notNull(), + /** Opt-in: publish so other agents can call it as a capability. */ + shareAsCapability: boolean("share_as_capability").notNull().default(false), + enabled: boolean("enabled").notNull().default(true), + ...timestamps, + }, + (table) => [index("product_actions_product_id_idx").on(table.productId)], +); + +export type Product = typeof products.$inferSelect; +export type NewProduct = typeof products.$inferInsert; +export type ProductContent = typeof productContent.$inferSelect; +export type NewProductContent = typeof productContent.$inferInsert; +export type ProductAction = typeof productActions.$inferSelect; +export type NewProductAction = typeof productActions.$inferInsert; diff --git a/packages/database/src/schema/relations.ts b/packages/database/src/schema/relations.ts index 85babd9..ed5e7df 100644 --- a/packages/database/src/schema/relations.ts +++ b/packages/database/src/schema/relations.ts @@ -6,6 +6,7 @@ import { agents } from "./agents"; import { payments } from "./payments"; import { apiKeys } from "./api-keys"; import { chatThreads, chatMessages } from "./chat"; +import { products, productContent, productActions } from "./products"; // Typed relations enable Drizzle's relational query API (db.query.users.findMany // with `with: { wallets: true }`, etc.) without hand-written joins. @@ -15,6 +16,7 @@ export const usersRelations = relations(users, ({ many }) => ({ agents: many(agents), capabilities: many(capabilities), apiKeys: many(apiKeys), + products: many(products), })); export const walletsRelations = relations(wallets, ({ one, many }) => ({ @@ -50,3 +52,17 @@ export const chatThreadsRelations = relations(chatThreads, ({ one, many }) => ({ export const chatMessagesRelations = relations(chatMessages, ({ one }) => ({ thread: one(chatThreads, { fields: [chatMessages.threadId], references: [chatThreads.id] }), })); + +export const productsRelations = relations(products, ({ one, many }) => ({ + owner: one(users, { fields: [products.ownerId], references: [users.id] }), + content: many(productContent), + actions: many(productActions), +})); + +export const productContentRelations = relations(productContent, ({ one }) => ({ + product: one(products, { fields: [productContent.productId], references: [products.id] }), +})); + +export const productActionsRelations = relations(productActions, ({ one }) => ({ + product: one(products, { fields: [productActions.productId], references: [products.id] }), +}));