Skip to content

Commit ae2c6d7

Browse files
committed
feat: let users delete operator chats
Signed-off-by: zebbern <185730623+zebbern@users.noreply.github.com>
1 parent 8d1c31f commit ae2c6d7

12 files changed

Lines changed: 402 additions & 32 deletions

File tree

backend/src/operator/__tests__/operator.repository.spec.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,17 +78,23 @@ function chainable(rows: unknown[], calls: QueryCall[], query: number) {
7878
return self;
7979
}
8080

81-
function repositoryWithSelects(selectResults: unknown[][], insertResults: unknown[][] = []) {
81+
function repositoryWithSelects(
82+
selectResults: unknown[][],
83+
insertResults: unknown[][] = [],
84+
deleteResults: unknown[][] = [],
85+
) {
8286
const calls: QueryCall[] = [];
8387
let query = 0;
8488
let insert = 0;
89+
let deletion = 0;
8590
const tx = {
8691
select: mock(() => {
8792
const current = query++;
8893
return chainable(selectResults[current] ?? [], calls, current);
8994
}),
9095
insert: mock(() => chainable(insertResults[insert++] ?? [], calls, query++)),
9196
update: mock(() => chainable([], calls, query++)),
97+
delete: mock(() => chainable(deleteResults[deletion++] ?? [], calls, query++)),
9298
};
9399
const db = {
94100
transaction: mock(async (handler: (executor: typeof tx) => Promise<unknown>) => handler(tx)),
@@ -98,6 +104,7 @@ function repositoryWithSelects(selectResults: unknown[][], insertResults: unknow
98104
repository: new OperatorRepository(db as never, audit as unknown as AuditLogService),
99105
tx,
100106
calls,
107+
audit,
101108
};
102109
}
103110

@@ -151,6 +158,61 @@ function createActionInput(argumentsValue: Record<string, unknown>) {
151158
};
152159
}
153160

161+
describe('OperatorRepository.deleteSession', () => {
162+
it('locks and deletes an owned session with no active turn', async () => {
163+
const { repository, tx, calls, audit } = repositoryWithSelects(
164+
[[session], []],
165+
[],
166+
[[session]],
167+
);
168+
169+
await expect(
170+
repository.deleteSession({
171+
sessionId: SESSION_ID,
172+
owner: { organizationId: auth.organizationId!, userId: auth.userId! },
173+
auth,
174+
}),
175+
).resolves.toEqual(session);
176+
177+
expect(calls.find((call) => call.query === 0 && call.method === 'for')?.args).toEqual([
178+
'update',
179+
]);
180+
expect(tx.delete).toHaveBeenCalledTimes(1);
181+
expect(audit.recordDurableWithExecutor).toHaveBeenCalledWith(tx, auth, {
182+
action: 'operator.session.delete',
183+
resourceType: 'operator_session',
184+
resourceId: SESSION_ID,
185+
resourceName: 'Session',
186+
});
187+
});
188+
189+
it('rejects deletion while a durable turn is active', async () => {
190+
const { repository, tx } = repositoryWithSelects([[session], [{ id: ACTIVE_TURN_ID }]]);
191+
192+
await expect(
193+
repository.deleteSession({
194+
sessionId: SESSION_ID,
195+
owner: { organizationId: auth.organizationId!, userId: auth.userId! },
196+
auth,
197+
}),
198+
).rejects.toThrow('Stop or wait for the active Operator turn before deleting');
199+
expect(tx.delete).not.toHaveBeenCalled();
200+
});
201+
202+
it('does not delete a session outside the requested owner scope', async () => {
203+
const { repository, tx } = repositoryWithSelects([[]]);
204+
205+
await expect(
206+
repository.deleteSession({
207+
sessionId: SESSION_ID,
208+
owner: { organizationId: 'another-org', userId: 'another-user' },
209+
auth,
210+
}),
211+
).resolves.toBeUndefined();
212+
expect(tx.delete).not.toHaveBeenCalled();
213+
});
214+
});
215+
154216
describe('OperatorRepository.createTurn', () => {
155217
it('locks the session before rejecting a distinct active turn', async () => {
156218
const { repository, tx, calls } = repositoryWithSelects([

backend/src/operator/__tests__/operator.service.spec.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ describe('OperatorService', () => {
105105
beforeEach(() => {
106106
repository = {
107107
createSession: vi.fn().mockResolvedValue(sessionRecord()),
108+
deleteSession: vi.fn().mockResolvedValue(sessionRecord()),
108109
findSession: vi.fn().mockResolvedValue(sessionRecord()),
109110
createTurn: vi
110111
.fn()
@@ -295,6 +296,16 @@ describe('OperatorService', () => {
295296
);
296297
});
297298

299+
it('deletes only within the authenticated user owner scope', async () => {
300+
await service.deleteSession(auth, SESSION_ID);
301+
302+
expect(repository.deleteSession).toHaveBeenCalledWith({
303+
sessionId: SESSION_ID,
304+
owner: { organizationId: 'operator-org', userId: 'operator-user' },
305+
auth,
306+
});
307+
});
308+
298309
it('keeps legacy route-only turn rows readable in the public session projection', async () => {
299310
const legacyContext = {
300311
path: '/workflows/55555555-5555-4555-8555-555555555555',

backend/src/operator/operator.controller.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
Body,
33
Controller,
4+
Delete,
45
Get,
56
HttpCode,
67
HttpStatus,
@@ -9,7 +10,7 @@ import {
910
Post,
1011
Res,
1112
} from '@nestjs/common';
12-
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
13+
import { ApiNoContentResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
1314
import type { Response } from 'express';
1415
import { ZodValidationPipe } from 'nestjs-zod';
1516

@@ -134,6 +135,17 @@ export class OperatorController {
134135
return this.operatorService.updateSession(auth, params.id, body);
135136
}
136137

138+
@Delete('sessions/:id')
139+
@HttpCode(HttpStatus.NO_CONTENT)
140+
@ApiOperation({ summary: 'Delete an Operator session owned by the current user' })
141+
@ApiNoContentResponse({ description: 'Operator session deleted successfully' })
142+
async deleteSession(
143+
@CurrentAuth() auth: AuthContext | null,
144+
@Param(new ZodValidationPipe(OperatorIdParamSchema)) params: OperatorIdParamDto,
145+
): Promise<void> {
146+
await this.operatorService.deleteSession(auth, params.id);
147+
}
148+
137149
@Post('sessions/:id/turns')
138150
@HttpCode(HttpStatus.ACCEPTED)
139151
@ApiOperation({ summary: 'Submit a durable Operator turn' })

backend/src/operator/operator.repository.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,56 @@ export class OperatorRepository {
251251
});
252252
}
253253

254+
async deleteSession(input: {
255+
sessionId: string;
256+
owner: { organizationId: string; userId: string };
257+
auth: AuthContext;
258+
}): Promise<OperatorSessionRecord | undefined> {
259+
return this.db.transaction(async (tx) => {
260+
const [session] = await tx
261+
.select()
262+
.from(operatorSessionsTable)
263+
.where(
264+
and(
265+
eq(operatorSessionsTable.id, input.sessionId),
266+
eq(operatorSessionsTable.organizationId, input.owner.organizationId),
267+
eq(operatorSessionsTable.userId, input.owner.userId),
268+
),
269+
)
270+
.for('update')
271+
.limit(1);
272+
if (!session) return undefined;
273+
274+
const [activeTurn] = await tx
275+
.select({ id: operatorTurnsTable.id })
276+
.from(operatorTurnsTable)
277+
.where(
278+
and(
279+
eq(operatorTurnsTable.sessionId, session.id),
280+
inArray(operatorTurnsTable.status, [...ACTIVE_OPERATOR_TURN_STATUSES]),
281+
),
282+
)
283+
.limit(1);
284+
if (activeTurn) {
285+
throw new ConflictException('Stop or wait for the active Operator turn before deleting');
286+
}
287+
288+
const [deleted] = await tx
289+
.delete(operatorSessionsTable)
290+
.where(eq(operatorSessionsTable.id, session.id))
291+
.returning();
292+
if (!deleted) return undefined;
293+
294+
await this.auditLogService.recordDurableWithExecutor(tx, input.auth, {
295+
action: 'operator.session.delete',
296+
resourceType: 'operator_session',
297+
resourceId: deleted.id,
298+
resourceName: deleted.title,
299+
});
300+
return deleted;
301+
});
302+
}
303+
254304
async listTurns(sessionId: string): Promise<OperatorTurnRecord[]> {
255305
return this.db
256306
.select()

backend/src/operator/operator.service.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,16 @@ export class OperatorService {
194194
return this.toSessionSummary(updated, latestTurn ?? null, latestTurnActions);
195195
}
196196

197+
async deleteSession(auth: AuthContext | null, sessionId: string): Promise<void> {
198+
const user = this.requireUserAuth(auth);
199+
const deleted = await this.repository.deleteSession({
200+
sessionId,
201+
owner: { organizationId: user.organizationId, userId: user.userId },
202+
auth: user,
203+
});
204+
if (!deleted) throw new NotFoundException('Operator session not found');
205+
}
206+
197207
async createTurn(
198208
auth: AuthContext | null,
199209
sessionId: string,

frontend/src/hooks/queries/useOperatorQueries.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,16 @@ export function operatorSessionHasActiveTurn(
8383
if (!latestTurn) return false;
8484
if (!ACTIVE_TURN_STATUSES.has(latestTurn.status)) return false;
8585
const activityLatestTurn = activitySummary?.latestTurn;
86-
if (activityLatestTurn?.id === latestTurn.id) {
87-
return ACTIVE_TURN_STATUSES.has(activityLatestTurn.status);
86+
if (activitySummary && activityLatestTurn?.id === latestTurn.id) {
87+
return operatorSessionSummaryHasActiveTurn(activitySummary);
8888
}
8989
return true;
9090
}
9191

92+
export function operatorSessionSummaryHasActiveTurn(session: OperatorSessionSummary): boolean {
93+
return Boolean(session.latestTurn && ACTIVE_TURN_STATUSES.has(session.latestTurn.status));
94+
}
95+
9296
export function getOperatorSessionLatestTurnError(session: OperatorSessionDetail): string | null {
9397
const latestTurn = session.turns[session.turns.length - 1];
9498
return latestTurn?.status === 'failed' ? latestTurn.error : null;
@@ -561,6 +565,19 @@ export function useUpdateOperatorSession() {
561565
});
562566
}
563567

568+
export function useDeleteOperatorSession() {
569+
const queryClient = useQueryClient();
570+
571+
return useMutation({
572+
mutationFn: (sessionId: string) => api.operator.deleteSession(sessionId),
573+
meta: { suppressGlobalError: true },
574+
onSuccess: (_result, sessionId) => {
575+
queryClient.removeQueries({ queryKey: queryKeys.operator.session(sessionId) });
576+
void queryClient.invalidateQueries({ queryKey: queryKeys.operator.sessions() });
577+
},
578+
});
579+
}
580+
564581
export function useCreateOperatorTurn() {
565582
const queryClient = useQueryClient();
566583

0 commit comments

Comments
 (0)