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 <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
parent
cfd7a7b783
commit
014473a7ac
@ -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
|
||||
|
||||
|
@ -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 });
|
||||
});
|
||||
}
|
@ -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<AccountProfile>, updateSeq: number, updateId: string): UpdatePayload {
|
||||
return {
|
||||
id: updateId,
|
||||
|
108
sources/app/session/sessionDelete.ts
Normal file
108
sources/app/session/sessionDelete.ts
Normal file
@ -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<boolean> {
|
||||
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;
|
||||
});
|
||||
}
|
Loading…
Reference in New Issue
Block a user