Skip to content

Commit c0a93b0

Browse files
committed
merge: propagate review-comment fixes from feat/dashboard-agent-flows
2 parents 83b7f07 + 2932b88 commit c0a93b0

5 files changed

Lines changed: 103 additions & 7 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Deleting a dashboard agent chat is now scoped to your organization, so a chat can only be removed from within the org it belongs to.

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
289289
// handover was dispatched and no message was sent: a session the call did create in
290290
// spite of the error idles out having done nothing. The empty row is all there is to undo.
291291
// Swallowed so the start's own error is what surfaces and gets logged.
292-
await softDeleteChat(dashboardAgentDb, { chatId, userId }).catch((cleanupError) => {
292+
await softDeleteChat(dashboardAgentDb, {
293+
chatId,
294+
userId,
295+
organizationId: project.organizationId,
296+
}).catch((cleanupError) => {
293297
logger.error("Failed to remove a dashboard agent chat whose start failed", {
294298
chatId,
295299
error: cleanupError,
@@ -456,8 +460,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
456460
}
457461

458462
case "delete": {
459-
// `softDeleteChat` is owner-scoped but takes no org, so the org scope has to be
460-
// enforced here.
463+
// Existence check gives a 404 for a chat this caller can't see; the delete itself
464+
// is org- and owner-scoped too.
461465
if (
462466
!(await chatExists(dashboardAgentDb, {
463467
chatId,
@@ -467,7 +471,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
467471
) {
468472
return json({ error: "Chat not found" }, { status: 404 });
469473
}
470-
await softDeleteChat(dashboardAgentDb, { chatId, userId });
474+
await softDeleteChat(dashboardAgentDb, {
475+
chatId,
476+
userId,
477+
organizationId: project.organizationId,
478+
});
471479
return json({ ok: true });
472480
}
473481
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import {
2+
createChat,
3+
createDashboardAgentDb,
4+
listChats,
5+
softDeleteChat,
6+
type DashboardAgentDb,
7+
type DashboardAgentDbClient,
8+
} from "@internal/dashboard-agent-db";
9+
import { postgresTest } from "@internal/testcontainers";
10+
import type { PrismaClient } from "@trigger.dev/database";
11+
import { readdirSync, readFileSync } from "node:fs";
12+
import path from "node:path";
13+
import { afterEach, describe, expect } from "vitest";
14+
15+
/** Replays every migration in order, so a new migration can't leave the suite on a stale schema. */
16+
async function applyAgentSchema(prisma: PrismaClient) {
17+
const folder = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle");
18+
const migrations = readdirSync(folder)
19+
.filter((file) => file.endsWith(".sql"))
20+
.sort();
21+
for (const name of migrations) {
22+
const sql = readFileSync(path.join(folder, name), "utf8");
23+
for (const statement of sql.split("--> statement-breakpoint")) {
24+
const trimmed = statement.trim();
25+
if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed);
26+
}
27+
}
28+
}
29+
30+
let agentDbClient: DashboardAgentDbClient | undefined;
31+
32+
async function boot(prisma: PrismaClient, connectionUri: string): Promise<DashboardAgentDb> {
33+
await applyAgentSchema(prisma);
34+
agentDbClient = createDashboardAgentDb(connectionUri, { max: 2 });
35+
return agentDbClient.db;
36+
}
37+
38+
afterEach(async () => {
39+
await agentDbClient?.close();
40+
agentDbClient = undefined;
41+
});
42+
43+
const ORG = "org_owner";
44+
const OTHER_ORG = "org_other";
45+
const USER = "user_owner";
46+
47+
describe("softDeleteChat tenant isolation", () => {
48+
postgresTest(
49+
"a soft-delete scoped to another org leaves the chat intact",
50+
async ({ prisma, postgresContainer }) => {
51+
const db = await boot(prisma, postgresContainer.getConnectionUri());
52+
53+
await createChat(db, { id: "chat_1", organizationId: ORG, userId: USER });
54+
55+
// Right user, wrong org: must not delete.
56+
const wrongOrg = await softDeleteChat(db, {
57+
chatId: "chat_1",
58+
userId: USER,
59+
organizationId: OTHER_ORG,
60+
});
61+
expect(wrongOrg.deleted).toBe(false);
62+
expect(await listChats(db, { organizationId: ORG, userId: USER })).toHaveLength(1);
63+
64+
// Right org and user: deletes.
65+
const rightOrg = await softDeleteChat(db, {
66+
chatId: "chat_1",
67+
userId: USER,
68+
organizationId: ORG,
69+
});
70+
expect(rightOrg.deleted).toBe(true);
71+
expect(await listChats(db, { organizationId: ORG, userId: USER })).toHaveLength(0);
72+
},
73+
30_000
74+
);
75+
});

internal-packages/dashboard-agent-db/src/queries.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,12 +225,18 @@ export async function setChatPinned(
225225
/** Owner-scoped: a client chatId can only delete the caller's own chat. */
226226
export async function softDeleteChat(
227227
db: DashboardAgentDb,
228-
params: { chatId: string; userId: string }
228+
params: { chatId: string; userId: string; organizationId: string }
229229
): Promise<{ deleted: boolean }> {
230230
const deleted = await db
231231
.update(chats)
232232
.set({ deletedAt: sql`now()`, updatedAt: sql`now()` })
233-
.where(and(eq(chats.id, params.chatId), eq(chats.userId, params.userId)))
233+
.where(
234+
and(
235+
eq(chats.id, params.chatId),
236+
eq(chats.userId, params.userId),
237+
eq(chats.organizationId, params.organizationId)
238+
)
239+
)
234240
.returning({ id: chats.id });
235241

236242
return { deleted: deleted.length > 0 };

internal-packages/dashboard-agent/src/dashboard-agent.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,7 @@ export const dashboardAgent = chat.agent({
427427
turn,
428428
uiMessages,
429429
newMessages,
430+
newUIMessages,
430431
responseMessage,
431432
clientData,
432433
chatAccessToken,
@@ -453,7 +454,7 @@ export const dashboardAgent = chat.agent({
453454
// operation is what could leave a terminal row whose card never arrived — and the
454455
// stale sweep only selects `in_progress`, so nothing would ever repair it.
455456
// Only what this turn produced may be finalised; the rest of the snapshot is history.
456-
const produced = [...(newMessages ?? []), ...(responseMessage ? [responseMessage] : [])]
457+
const produced = [...(newUIMessages ?? []), ...(responseMessage ? [responseMessage] : [])]
457458
.map((message) => (message as { id?: unknown }).id)
458459
.filter((id): id is string => typeof id === "string");
459460

0 commit comments

Comments
 (0)