Add minimal stock listing app: actions, Postgres schema, stocks page, Claude Agent SDK harness
- Add stocks table (server/db) with list/get/add/update/delete actions, scoped per user, backing both the chat agent and the /stocks UI via useActionQuery/useActionMutation - Add mobile-first /stocks page (shadcn table/cards, watchlist, search) and wire it into the sidebar, navigation state, and view-screen context - Install the platform's Claude Agent SDK harness (server/plugins, server/routes/api/claude-agent) so the app agent runs on the platform's own Claude subscription instead of the Builder.io gateway; fix the vendored turn.post.ts route to resolve the session and wrap it in runWithRequestContext, since custom /api/ routes aren't auto-wrapped in request context the way /_agent-native/actions/* are - Add shadcn table/badge/dialog/select components (Tabler icons, not lucide)
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { defineAction } from "@agent-native/core/action";
|
||||
import { and, eq } from "@agent-native/core/db/schema";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getDb } from "../server/db/index.js";
|
||||
import { stocks } from "../server/db/schema.js";
|
||||
|
||||
export default defineAction({
|
||||
description:
|
||||
"Add a new stock listing. Fails if the symbol is already listed for this user.",
|
||||
schema: z.object({
|
||||
symbol: z.string().min(1).max(10).describe("Ticker symbol, e.g. AAPL"),
|
||||
companyName: z.string().min(1).describe("Company name"),
|
||||
exchange: z
|
||||
.string()
|
||||
.default("NASDAQ")
|
||||
.describe("Exchange, e.g. NASDAQ, NYSE"),
|
||||
sector: z.string().optional().describe("Sector, e.g. Technology"),
|
||||
currency: z.string().default("USD").describe("Currency code, e.g. USD"),
|
||||
price: z.coerce.number().default(0).describe("Current price"),
|
||||
changePercent: z.coerce
|
||||
.number()
|
||||
.default(0)
|
||||
.describe("Price change percent, e.g. 1.5 or -2.3"),
|
||||
marketCap: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Market capitalization"),
|
||||
}),
|
||||
run: async (args, ctx) => {
|
||||
if (!ctx?.userEmail) throw new Error("Authentication required.");
|
||||
const db = getDb();
|
||||
const symbol = args.symbol.toUpperCase();
|
||||
|
||||
const existing = await db
|
||||
.select({ id: stocks.id })
|
||||
.from(stocks)
|
||||
.where(and(eq(stocks.ownerEmail, ctx.userEmail), eq(stocks.symbol, symbol)));
|
||||
if (existing[0]) {
|
||||
throw new Error(`${symbol} is already listed.`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const row = {
|
||||
id: randomUUID(),
|
||||
symbol,
|
||||
companyName: args.companyName,
|
||||
exchange: args.exchange,
|
||||
sector: args.sector ?? null,
|
||||
currency: args.currency,
|
||||
price: args.price,
|
||||
changePercent: args.changePercent,
|
||||
marketCap: args.marketCap ?? null,
|
||||
watchlisted: false,
|
||||
ownerEmail: ctx.userEmail,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await db.insert(stocks).values(row);
|
||||
return row;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineAction } from "@agent-native/core/action";
|
||||
import { and, eq } from "@agent-native/core/db/schema";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getDb } from "../server/db/index.js";
|
||||
import { stocks } from "../server/db/schema.js";
|
||||
|
||||
export default defineAction({
|
||||
description: "Delete a stock listing by id.",
|
||||
schema: z.object({
|
||||
id: z.string().describe("Stock listing id"),
|
||||
}),
|
||||
http: { method: "DELETE" },
|
||||
run: async (args, ctx) => {
|
||||
if (!ctx?.userEmail) throw new Error("Authentication required.");
|
||||
const db = getDb();
|
||||
const deleted = await db
|
||||
.delete(stocks)
|
||||
.where(and(eq(stocks.id, args.id), eq(stocks.ownerEmail, ctx.userEmail)))
|
||||
.returning();
|
||||
if (!deleted[0]) throw new Error("Stock not found.");
|
||||
return { deleted: true, id: args.id };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { defineAction } from "@agent-native/core/action";
|
||||
import { and, eq } from "@agent-native/core/db/schema";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getDb } from "../server/db/index.js";
|
||||
import { stocks } from "../server/db/schema.js";
|
||||
|
||||
export default defineAction({
|
||||
description: "Get a single stock listing by id or symbol.",
|
||||
schema: z.object({
|
||||
id: z.string().optional().describe("Stock listing id"),
|
||||
symbol: z.string().optional().describe("Stock ticker symbol"),
|
||||
}),
|
||||
http: { method: "GET" },
|
||||
readOnly: true,
|
||||
run: async (args, ctx) => {
|
||||
if (!ctx?.userEmail) throw new Error("Authentication required.");
|
||||
if (!args.id && !args.symbol) {
|
||||
throw new Error("Either id or symbol is required.");
|
||||
}
|
||||
const db = getDb();
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(stocks)
|
||||
.where(
|
||||
and(
|
||||
eq(stocks.ownerEmail, ctx.userEmail),
|
||||
args.id
|
||||
? eq(stocks.id, args.id)
|
||||
: eq(stocks.symbol, args.symbol!.toUpperCase()),
|
||||
),
|
||||
);
|
||||
const stock = rows[0];
|
||||
if (!stock) throw new Error("Stock not found.");
|
||||
return stock;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { defineAction } from "@agent-native/core/action";
|
||||
import { and, desc, eq, or, sql } from "@agent-native/core/db/schema";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getDb } from "../server/db/index.js";
|
||||
import { stocks } from "../server/db/schema.js";
|
||||
|
||||
function booleanParam(value: string | undefined): boolean | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
return value === "true" || value === "1";
|
||||
}
|
||||
|
||||
export default defineAction({
|
||||
description:
|
||||
"List stock listings, optionally filtered by search text (symbol or company name), exchange, or watchlisted status.",
|
||||
schema: z.object({
|
||||
search: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Filter by symbol or company name (case-insensitive substring match)"),
|
||||
exchange: z.string().optional().describe("Filter by exchange (e.g. NASDAQ, NYSE)"),
|
||||
watchlistedOnly: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Set to \"true\" to only return watchlisted stocks"),
|
||||
}),
|
||||
http: { method: "GET" },
|
||||
readOnly: true,
|
||||
run: async (args, ctx) => {
|
||||
if (!ctx?.userEmail) throw new Error("Authentication required.");
|
||||
const db = getDb();
|
||||
|
||||
const conditions = [eq(stocks.ownerEmail, ctx.userEmail)];
|
||||
if (args.search) {
|
||||
const term = `%${args.search.toLowerCase()}%`;
|
||||
conditions.push(
|
||||
or(
|
||||
sql`lower(${stocks.symbol}) like ${term}`,
|
||||
sql`lower(${stocks.companyName}) like ${term}`,
|
||||
)!,
|
||||
);
|
||||
}
|
||||
if (args.exchange) conditions.push(eq(stocks.exchange, args.exchange));
|
||||
if (booleanParam(args.watchlistedOnly)) {
|
||||
conditions.push(eq(stocks.watchlisted, true));
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(stocks)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(stocks.watchlisted), stocks.symbol);
|
||||
|
||||
return { stocks: rows };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { defineAction } from "@agent-native/core/action";
|
||||
import { and, eq } from "@agent-native/core/db/schema";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getDb } from "../server/db/index.js";
|
||||
import { stocks } from "../server/db/schema.js";
|
||||
|
||||
export default defineAction({
|
||||
description:
|
||||
"Update fields on an existing stock listing (patch — only pass the fields that change). Use this to edit price, change percent, sector, or toggle the watchlist.",
|
||||
schema: z.object({
|
||||
id: z.string().describe("Stock listing id"),
|
||||
companyName: z.string().optional(),
|
||||
exchange: z.string().optional(),
|
||||
sector: z.string().optional(),
|
||||
currency: z.string().optional(),
|
||||
price: z.coerce.number().optional(),
|
||||
changePercent: z.coerce.number().optional(),
|
||||
marketCap: z.coerce.number().optional(),
|
||||
watchlisted: z
|
||||
.enum(["true", "false"])
|
||||
.optional()
|
||||
.describe("Set to \"true\" or \"false\" to toggle the watchlist"),
|
||||
}),
|
||||
http: { method: "PUT" },
|
||||
run: async (args, ctx) => {
|
||||
if (!ctx?.userEmail) throw new Error("Authentication required.");
|
||||
const { id, watchlisted, ...rest } = args;
|
||||
const db = getDb();
|
||||
|
||||
const patch: Record<string, unknown> = { ...rest };
|
||||
if (watchlisted !== undefined) patch.watchlisted = watchlisted === "true";
|
||||
if (Object.keys(patch).length === 0) {
|
||||
throw new Error("No fields to update.");
|
||||
}
|
||||
patch.updatedAt = new Date().toISOString();
|
||||
|
||||
const updated = await db
|
||||
.update(stocks)
|
||||
.set(patch)
|
||||
.where(and(eq(stocks.id, id), eq(stocks.ownerEmail, ctx.userEmail)))
|
||||
.returning();
|
||||
|
||||
if (!updated[0]) throw new Error("Stock not found.");
|
||||
return updated[0];
|
||||
},
|
||||
});
|
||||
+15
-1
@@ -9,20 +9,34 @@
|
||||
|
||||
import { defineAction } from "@agent-native/core/action";
|
||||
import { readAppState } from "@agent-native/core/application-state";
|
||||
import { eq } from "@agent-native/core/db/schema";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getDb } from "../server/db/index.js";
|
||||
import { stocks } from "../server/db/schema.js";
|
||||
|
||||
export default defineAction({
|
||||
description:
|
||||
"See what the user is currently looking at on screen. Returns the current navigation state for the chat-first app. Always call this first before taking any action.",
|
||||
schema: z.object({}),
|
||||
http: false,
|
||||
readOnly: true,
|
||||
run: async () => {
|
||||
run: async (_args, ctx) => {
|
||||
const navigation = await readAppState("navigation");
|
||||
|
||||
const screen: Record<string, unknown> = {};
|
||||
if (navigation) screen.navigation = navigation;
|
||||
|
||||
const view = (navigation as { view?: string } | null)?.view;
|
||||
if (view === "stocks" && ctx?.userEmail) {
|
||||
const db = getDb();
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(stocks)
|
||||
.where(eq(stocks.ownerEmail, ctx.userEmail));
|
||||
screen.stocks = { count: rows.length, listings: rows };
|
||||
}
|
||||
|
||||
if (Object.keys(screen).length === 0) {
|
||||
return "No application state found. Is the app running?";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user