From 014473a7ac10305f9d8468be180d037ad2173202 Mon Sep 17 00:00:00 2001 From: Steve Korshakov Date: Sat, 20 Sep 2025 21:17:48 -0700 Subject: [PATCH] feat: implement session deletion with cascade and socket notifications - Add sessionDelete action to handle deletion of sessions and all related data - Delete session messages, usage reports, and access keys in proper order - Add delete-session event type to eventRouter for real-time notifications - Add DELETE /v1/sessions/:sessionId endpoint with ownership verification - Send socket notification to all user connections after successful deletion - Add idempotency rule to CLAUDE.md for API operations Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- CLAUDE.md | 1 + sources/app/api/routes/sessionRoutes.ts | 22 +++++ sources/app/events/eventRouter.ts | 15 ++++ sources/app/session/sessionDelete.ts | 108 ++++++++++++++++++++++++ 4 files changed, 146 insertions(+) create mode 100644 sources/app/session/sessionDelete.ts diff --git a/CLAUDE.md b/CLAUDE.md index e311bc5..ebf3394 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,6 +160,7 @@ The project has pending Prisma migrations that need to be applied: - Routes are in `/sources/apps/api/routes` - Use Fastify with Zod for type-safe route definitions - Always validate inputs using Zod +- **Idempotency**: Design all operations to be idempotent - clients may retry requests automatically and the backend must handle multiple invocations of the same operation gracefully, producing the same result as a single invocation ## Docker Deployment diff --git a/sources/app/api/routes/sessionRoutes.ts b/sources/app/api/routes/sessionRoutes.ts index 86f8652..59aec4d 100644 --- a/sources/app/api/routes/sessionRoutes.ts +++ b/sources/app/api/routes/sessionRoutes.ts @@ -6,6 +6,7 @@ import { Prisma } from "@prisma/client"; import { log } from "@/utils/log"; import { randomKeyNaked } from "@/utils/randomKeyNaked"; import { allocateUserSeq } from "@/storage/seq"; +import { sessionDelete } from "@/app/session/sessionDelete"; export function sessionRoutes(app: Fastify) { @@ -352,4 +353,25 @@ export function sessionRoutes(app: Fastify) { })) }); }); + + // Delete session + app.delete('/v1/sessions/:sessionId', { + schema: { + params: z.object({ + sessionId: z.string() + }) + }, + preHandler: app.authenticate + }, async (request, reply) => { + const userId = request.userId; + const { sessionId } = request.params; + + const deleted = await sessionDelete({ uid: userId }, sessionId); + + if (!deleted) { + return reply.code(404).send({ error: 'Session not found or not owned by user' }); + } + + return reply.send({ success: true }); + }); } \ No newline at end of file diff --git a/sources/app/events/eventRouter.ts b/sources/app/events/eventRouter.ts index ecf5437..d3a2f30 100644 --- a/sources/app/events/eventRouter.ts +++ b/sources/app/events/eventRouter.ts @@ -130,6 +130,9 @@ export type UpdateEvent = { } | { type: 'delete-artifact'; artifactId: string; +} | { + type: 'delete-session'; + sessionId: string; } | { type: 'relationship-updated'; uid: string; @@ -386,6 +389,18 @@ export function buildUpdateSessionUpdate(sessionId: string, updateSeq: number, u }; } +export function buildDeleteSessionUpdate(sessionId: string, updateSeq: number, updateId: string): UpdatePayload { + return { + id: updateId, + seq: updateSeq, + body: { + t: 'delete-session', + sid: sessionId + }, + createdAt: Date.now() + }; +} + export function buildUpdateAccountUpdate(userId: string, profile: Partial, updateSeq: number, updateId: string): UpdatePayload { return { id: updateId, diff --git a/sources/app/session/sessionDelete.ts b/sources/app/session/sessionDelete.ts new file mode 100644 index 0000000..7eb44ef --- /dev/null +++ b/sources/app/session/sessionDelete.ts @@ -0,0 +1,108 @@ +import { Context } from "@/context"; +import { inTx, afterTx } from "@/storage/inTx"; +import { eventRouter, buildDeleteSessionUpdate } from "@/app/events/eventRouter"; +import { allocateUserSeq } from "@/storage/seq"; +import { randomKeyNaked } from "@/utils/randomKeyNaked"; +import { log } from "@/utils/log"; + +/** + * Delete a session and all its related data. + * Handles: + * - Deleting all session messages + * - Deleting all usage reports for the session + * - Deleting all access keys for the session + * - Deleting the session itself + * - Sending socket notification to all connected clients + * + * @param ctx - Context with user information + * @param sessionId - ID of the session to delete + * @returns true if deletion was successful, false if session not found or not owned by user + */ +export async function sessionDelete(ctx: Context, sessionId: string): Promise { + return await inTx(async (tx) => { + // Verify session exists and belongs to the user + const session = await tx.session.findFirst({ + where: { + id: sessionId, + accountId: ctx.uid + } + }); + + if (!session) { + log({ + module: 'session-delete', + userId: ctx.uid, + sessionId + }, `Session not found or not owned by user`); + return false; + } + + // Delete all related data + // Note: Order matters to avoid foreign key constraint violations + + // 1. Delete session messages + const deletedMessages = await tx.sessionMessage.deleteMany({ + where: { sessionId } + }); + log({ + module: 'session-delete', + userId: ctx.uid, + sessionId, + deletedCount: deletedMessages.count + }, `Deleted ${deletedMessages.count} session messages`); + + // 2. Delete usage reports + const deletedReports = await tx.usageReport.deleteMany({ + where: { sessionId } + }); + log({ + module: 'session-delete', + userId: ctx.uid, + sessionId, + deletedCount: deletedReports.count + }, `Deleted ${deletedReports.count} usage reports`); + + // 3. Delete access keys + const deletedAccessKeys = await tx.accessKey.deleteMany({ + where: { sessionId } + }); + log({ + module: 'session-delete', + userId: ctx.uid, + sessionId, + deletedCount: deletedAccessKeys.count + }, `Deleted ${deletedAccessKeys.count} access keys`); + + // 4. Delete the session itself + await tx.session.delete({ + where: { id: sessionId } + }); + log({ + module: 'session-delete', + userId: ctx.uid, + sessionId + }, `Session deleted successfully`); + + // Send notification after transaction commits + afterTx(tx, async () => { + const updSeq = await allocateUserSeq(ctx.uid); + const updatePayload = buildDeleteSessionUpdate(sessionId, updSeq, randomKeyNaked(12)); + + log({ + module: 'session-delete', + userId: ctx.uid, + sessionId, + updateType: 'delete-session', + updatePayload: JSON.stringify(updatePayload) + }, `Emitting delete-session update to all user connections`); + + eventRouter.emitUpdate({ + userId: ctx.uid, + payload: updatePayload, + recipientFilter: { type: 'all-user-authenticated-connections' } + }); + }); + + return true; + }); +} \ No newline at end of file