Skip to content

Commit 4cf1e3c

Browse files
Implement self-service delete for chats (#41)
* Implement self-service delete for chats * Update button location * Add confirmation modal --------- Co-authored-by: Luc <[email protected]>
1 parent bc25ab3 commit 4cf1e3c

File tree

7 files changed

+211
-18
lines changed

7 files changed

+211
-18
lines changed

app/src/models/workshop/chat.rs

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,18 @@ pub struct WorkshopChat {
2121
}
2222

2323
impl WorkshopChat {
24-
pub async fn find_by_user_id(user_id: Uuid, state: &AppState) -> Result<Vec<Self>, sqlx::Error> {
25-
query_as("SELECT * FROM workshop_chats WHERE user_id = $1 ORDER BY created_at DESC")
24+
pub async fn find_by_user_id(
25+
user_id: Uuid,
26+
state: &AppState,
27+
) -> Result<Vec<Self>, sqlx::Error> {
28+
query_as("SELECT * FROM workshop_chats WHERE user_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC")
2629
.bind(user_id)
2730
.fetch_all(&state.database.pool)
2831
.await
2932
}
3033

3134
pub async fn find_by_id(chat_id: Uuid, state: &AppState) -> Result<Self, sqlx::Error> {
32-
query_as("SELECT * FROM workshop_chats WHERE chat_id = $1")
35+
query_as("SELECT * FROM workshop_chats WHERE chat_id = $1 AND deleted_at IS NULL")
3336
.bind(chat_id)
3437
.fetch_one(&state.database.pool)
3538
.await
@@ -42,20 +45,34 @@ impl WorkshopChat {
4245
.await
4346
}
4447

45-
pub async fn update_last_message(chat_id: &Uuid, message_id: &Uuid, state: &AppState) -> Result<Self, sqlx::Error> {
46-
query_as("UPDATE workshop_chats SET last_message_id = $1 WHERE chat_id = $2 RETURNING *")
48+
pub async fn update_last_message(
49+
chat_id: &Uuid,
50+
message_id: &Uuid,
51+
state: &AppState,
52+
) -> Result<Self, sqlx::Error> {
53+
query_as("UPDATE workshop_chats SET last_message_id = $1 WHERE chat_id = $2 AND deleted_at IS NULL RETURNING *")
4754
.bind(message_id)
4855
.bind(chat_id)
4956
.fetch_one(&state.database.pool)
5057
.await
5158
}
5259

53-
pub async fn update_summary(chat_id: &Uuid, summary: &str, state: &AppState) -> Result<Self, sqlx::Error> {
54-
query_as("UPDATE workshop_chats SET summary = $1 WHERE chat_id = $2 RETURNING *")
60+
pub async fn update_summary(
61+
chat_id: &Uuid,
62+
summary: &str,
63+
state: &AppState,
64+
) -> Result<Self, sqlx::Error> {
65+
query_as("UPDATE workshop_chats SET summary = $1 WHERE chat_id = $2 AND deleted_at IS NULL RETURNING *")
5566
.bind(summary)
5667
.bind(chat_id)
5768
.fetch_one(&state.database.pool)
5869
.await
5970
}
60-
}
6171

72+
pub async fn delete(chat_id: &Uuid, state: &AppState) -> Result<Self, sqlx::Error> {
73+
query_as("UPDATE workshop_chats SET deleted_at = NOW() WHERE chat_id = $1 AND deleted_at IS NULL RETURNING *")
74+
.bind(chat_id)
75+
.fetch_one(&state.database.pool)
76+
.await
77+
}
78+
}

app/src/models/workshop/message.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,11 @@ impl WorkshopMessage {
143143
) -> Result<Vec<Self>, sqlx::Error> {
144144
query_as!(
145145
Self,
146-
"SELECT message_id, chat_id, sender_role, message, created_at, parent_message_id, streaming_events, prompt_tokens, completion_tokens, total_tokens, reasoning_tokens, model_used FROM workshop_messages WHERE chat_id = $1 ORDER BY created_at ASC",
146+
"SELECT m.message_id, m.chat_id, m.sender_role, m.message, m.created_at, m.parent_message_id, m.streaming_events, m.prompt_tokens, m.completion_tokens, m.total_tokens, m.reasoning_tokens, m.model_used
147+
FROM workshop_messages m
148+
INNER JOIN workshop_chats c ON m.chat_id = c.chat_id
149+
WHERE m.chat_id = $1 AND c.deleted_at IS NULL
150+
ORDER BY m.created_at ASC",
147151
chat_id
148152
)
149153
.fetch_all(&state.database.pool)
@@ -159,10 +163,15 @@ impl WorkshopMessage {
159163
) -> Result<Vec<Self>, sqlx::Error> {
160164
query_as(
161165
r#"WITH RECURSIVE message_tree AS (
162-
SELECT message_id, chat_id, sender_role, message, created_at, parent_message_id, streaming_events, prompt_tokens, completion_tokens, total_tokens, reasoning_tokens, model_used FROM workshop_messages WHERE message_id = $1
166+
SELECT m.message_id, m.chat_id, m.sender_role, m.message, m.created_at, m.parent_message_id, m.streaming_events, m.prompt_tokens, m.completion_tokens, m.total_tokens, m.reasoning_tokens, m.model_used
167+
FROM workshop_messages m
168+
INNER JOIN workshop_chats c ON m.chat_id = c.chat_id
169+
WHERE m.message_id = $1 AND c.deleted_at IS NULL
163170
UNION ALL
164-
SELECT m.message_id, m.chat_id, m.sender_role, m.message, m.created_at, m.parent_message_id, m.streaming_events, m.prompt_tokens, m.completion_tokens, m.total_tokens, m.reasoning_tokens, m.model_used FROM workshop_messages m
171+
SELECT m.message_id, m.chat_id, m.sender_role, m.message, m.created_at, m.parent_message_id, m.streaming_events, m.prompt_tokens, m.completion_tokens, m.total_tokens, m.reasoning_tokens, m.model_used
172+
FROM workshop_messages m
165173
INNER JOIN message_tree mt ON m.message_id = mt.parent_message_id
174+
INNER JOIN workshop_chats c ON m.chat_id = c.chat_id AND c.deleted_at IS NULL
166175
)
167176
SELECT message_id, chat_id, sender_role, message, created_at, parent_message_id, streaming_events, prompt_tokens, completion_tokens, total_tokens, reasoning_tokens, model_used FROM message_tree
168177
ORDER BY created_at ASC"#,

app/src/models/workshop/snapshot.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,28 @@ pub struct WorkshopSnapshotResponse {
2929

3030
impl WorkshopSnapshot {
3131
pub async fn create(chat_id: Uuid, message_id: Uuid, user_id: Uuid, state: &AppState) -> Result<Self, sqlx::Error> {
32-
let snapshot = query_as!(WorkshopSnapshot, "INSERT INTO workshop_snapshots (chat_id, message_id, user_id) VALUES ($1, $2, $3) RETURNING *", chat_id, message_id, user_id)
32+
sqlx::query!(
33+
"SELECT chat_id FROM workshop_chats WHERE chat_id = $1 AND deleted_at IS NULL",
34+
chat_id
35+
)
36+
.fetch_one(&state.database.pool)
37+
.await?;
38+
39+
let snapshot = query_as!(WorkshopSnapshot,
40+
"INSERT INTO workshop_snapshots (chat_id, message_id, user_id) VALUES ($1, $2, $3) RETURNING *",
41+
chat_id, message_id, user_id)
3342
.fetch_one(&state.database.pool)
3443
.await?;
3544
Ok(snapshot)
3645
}
3746

3847
pub async fn get_by_snapshot_id(snapshot_id: Uuid, state: &AppState) -> Result<Self, sqlx::Error> {
39-
let snapshot = query_as!(WorkshopSnapshot, "SELECT * FROM workshop_snapshots WHERE snapshot_id = $1", snapshot_id)
48+
let snapshot = query_as!(WorkshopSnapshot,
49+
"SELECT s.snapshot_id, s.chat_id, s.user_id, s.message_id, s.created_at
50+
FROM workshop_snapshots s
51+
INNER JOIN workshop_chats c ON s.chat_id = c.chat_id
52+
WHERE s.snapshot_id = $1 AND c.deleted_at IS NULL",
53+
snapshot_id)
4054
.fetch_one(&state.database.pool)
4155
.await?;
4256
Ok(snapshot)

app/src/server/workshop/mod.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -845,4 +845,51 @@ impl WorkshopApi {
845845

846846
Ok(Json(messages))
847847
}
848+
849+
/// /ws/chat/:chat_id
850+
///
851+
/// Delete a chat and all associated messages and snapshots
852+
#[oai(
853+
path = "/ws/chat/:chat_id",
854+
method = "delete",
855+
tag = "ApiTags::Workshop"
856+
)]
857+
async fn delete_chat(
858+
&self,
859+
state: Data<&AppState>,
860+
auth_user: AuthUser,
861+
#[oai(style = "simple")] chat_id: Path<Uuid>,
862+
) -> Result<Json<serde_json::Value>> {
863+
let user_id = auth_user.0.user.user_id;
864+
865+
let chat = WorkshopChat::find_by_id(*chat_id, &state)
866+
.await
867+
.map_err(|e| {
868+
tracing::error!("Error finding chat: {:?}", e);
869+
poem::Error::from_status(StatusCode::INTERNAL_SERVER_ERROR)
870+
})?;
871+
872+
if chat.user_id != user_id {
873+
tracing::warn!(
874+
"User {} attempted to delete chat {} owned by {}",
875+
user_id,
876+
*chat_id,
877+
chat.user_id
878+
);
879+
return Err(poem::Error::from_status(StatusCode::FORBIDDEN));
880+
}
881+
882+
WorkshopChat::delete(&chat_id, &state).await.map_err(|e| {
883+
tracing::error!("Error deleting chat: {:?}", e);
884+
poem::Error::from_status(StatusCode::INTERNAL_SERVER_ERROR)
885+
})?;
886+
887+
tracing::info!(
888+
"Successfully deleted chat {} for user {}",
889+
*chat_id,
890+
user_id
891+
);
892+
893+
Ok(Json(serde_json::json!({ "success": true })))
894+
}
848895
}

web/src/api/schema.gen.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -800,7 +800,31 @@ export interface paths {
800800
};
801801
};
802802
};
803-
delete?: never;
803+
/**
804+
* /ws/chat/:chat_id
805+
* @description Delete a chat and all associated messages and snapshots
806+
*/
807+
delete: {
808+
parameters: {
809+
query?: never;
810+
header?: never;
811+
path: {
812+
chat_id: string;
813+
};
814+
cookie?: never;
815+
};
816+
requestBody?: never;
817+
responses: {
818+
200: {
819+
headers: {
820+
[name: string]: unknown;
821+
};
822+
content: {
823+
"application/json; charset=utf-8": unknown;
824+
};
825+
};
826+
};
827+
};
804828
options?: never;
805829
head?: never;
806830
patch?: never;

web/src/api/workshop.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,3 +252,18 @@ export const useWorkshopSnapshot = (snapshotId: string) => {
252252
},
253253
});
254254
};
255+
256+
export const useWorkshopDeleteChat = () => {
257+
return useMutation({
258+
mutationFn: async (chatId: string) => {
259+
await useApi('/ws/chat/{chat_id}', 'delete', {
260+
path: { chat_id: chatId },
261+
});
262+
},
263+
onSuccess: (_, chatId) => {
264+
queryClient.invalidateQueries({ queryKey: ['workshop', 'chats'] });
265+
queryClient.invalidateQueries({ queryKey: ['workshop', 'chat', chatId] });
266+
queryClient.invalidateQueries({ queryKey: ['workshop'] });
267+
},
268+
});
269+
};

web/src/components/workshop/WorkshopChatsNav.tsx

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
import { Link, useParams, useRouterState } from '@tanstack/react-router';
1+
import * as Dialog from '@radix-ui/react-dialog';
2+
import { Link, useNavigate, useParams, useRouterState } from '@tanstack/react-router';
23
import classNames from 'classnames';
4+
import { FC, useState } from 'react';
35
import { FiBarChart } from 'react-icons/fi';
6+
import { LuX } from 'react-icons/lu';
47

58
import { useAuth } from '@/api/auth';
6-
import { useWorkshopChats } from '@/api/workshop';
9+
import { useWorkshopChats, useWorkshopDeleteChat } from '@/api/workshop';
710

811
import { Tooltip } from '../tooltip/Tooltip';
912

@@ -29,6 +32,7 @@ const AuthenticatedWorkshopChats = () => {
2932
try {
3033
const params = useParams({ from: '/chat/$chatId' });
3134

35+
// eslint-disable-next-line prefer-destructuring
3236
chatId = params.chatId;
3337
} catch {
3438
// We're not on a /chat/$chatId route, so no chatId
@@ -60,8 +64,8 @@ const AuthenticatedWorkshopChats = () => {
6064
params={{ chatId: chat.chat_id }}
6165
hash={chat.last_message_id}
6266
className={classNames(
63-
'flex justify-between items-center hover:bg-secondary px-1.5 py-0.5 relative',
64-
chat.chat_id === chatId && 'bg-secondary'
67+
'flex justify-between items-center hover:bg-tertiary px-1.5 py-0.5 relative group/workshoplink',
68+
chat.chat_id === chatId && 'bg-tertiary'
6569
)}
6670
>
6771
<div className="w-full">
@@ -76,6 +80,9 @@ const AuthenticatedWorkshopChats = () => {
7680
>
7781
{chat.summary || 'Untitled conversation'}
7882
</Tooltip>
83+
<div className="absolute right-0 top-1/2 -translate-y-1/2 hidden group-hover/workshoplink:block">
84+
<DeleteButton chatId={chat.chat_id} currentChatId={chatId} />
85+
</div>
7986
</div>
8087
</Link>
8188
</li>
@@ -84,3 +91,63 @@ const AuthenticatedWorkshopChats = () => {
8491
</div>
8592
);
8693
};
94+
95+
export const DeleteButton: FC<{ chatId: string; currentChatId?: string }> = ({
96+
chatId,
97+
currentChatId,
98+
}) => {
99+
const { mutate: deleteChat } = useWorkshopDeleteChat();
100+
const navigate = useNavigate();
101+
const [open, setOpen] = useState(false);
102+
103+
const handleDelete = () => {
104+
deleteChat(chatId, {
105+
onSuccess() {
106+
setOpen(false);
107+
108+
if (chatId === currentChatId) {
109+
navigate({ to: '/chat/$chatId', params: { chatId: 'new' } });
110+
}
111+
},
112+
});
113+
};
114+
115+
return (
116+
<Dialog.Root open={open} onOpenChange={setOpen}>
117+
<Dialog.Trigger asChild>
118+
<button className="button flex items-center gap-2">
119+
<LuX />
120+
</button>
121+
</Dialog.Trigger>
122+
<Dialog.Portal>
123+
<Dialog.Overlay className="fixed inset-0 bg-black/50 z-40 data-[state=open]:animate-overlayShow overflow-y-scroll grid place-items-center">
124+
<Dialog.Content className="z-50 relative my-10 max-w-md shadow-[var(--shadow-6)] focus:outline-none data-[state=open]:animate-contentShow mx-auto w-full p-6 bg-primary space-y-4">
125+
<Dialog.Title className="text-xl font-bold">
126+
Delete Conversation
127+
</Dialog.Title>
128+
<Dialog.Description>
129+
Are you sure you want to delete this conversation? This action cannot be
130+
undone.
131+
</Dialog.Description>
132+
<div className="flex gap-3 justify-end">
133+
<Dialog.Close asChild>
134+
<button className="px-4 py-2 text-sm font-medium button">
135+
Cancel
136+
</button>
137+
</Dialog.Close>
138+
<button
139+
onClick={handleDelete}
140+
className="px-4 py-2 text-sm font-medium button button-red"
141+
>
142+
Delete
143+
</button>
144+
</div>
145+
<Dialog.Close className="absolute top-2 right-2 -translate-y-1/2 hover:bg-secondary rounded-full p-1">
146+
<LuX className="size-5" />
147+
</Dialog.Close>
148+
</Dialog.Content>
149+
</Dialog.Overlay>
150+
</Dialog.Portal>
151+
</Dialog.Root>
152+
);
153+
};

0 commit comments

Comments
 (0)