From a52c1659fc94626fd5290301ef3d86ce897c6927 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Tue, 21 Apr 2026 09:44:00 +0000 Subject: [PATCH] Stop a group from being orphaned when its only admin leaves (Task #40) The /conversations/:id/leave handler used to delete the leaver's participant row unconditionally. If the leaver was the only admin, the group survived but no one could rename it, change the picture, or add/remove members. If they were also the only participant, the empty conversation lingered forever. Server (artifacts/api-server/src/routes/conversations.ts): - Load all participants ordered by joinedAt before mutating anything. - If the leaver is the only participant, delete the conversation row (cascades clean up participants, messages, and reads) and skip the emit. - If the leaver is the only admin, promote the longest-tenured remaining participant (oldest joinedAt) to admin in the same flow. - Emit a `member_left` system message and, when applicable, an `admin_promoted` system message (with reason `previous_admin_left`) before removing the leaver, so members see both events live. Client (artifacts/teaboy-os/src/pages/chat.tsx + locales): - Render the new `member_left` and `admin_promoted` system message kinds with bilingual copy in en.json and ar.json. - Extended SystemMessageMeta with `promoted` and `reason` fields. Approach notes: - Chose auto-promotion over a chooser dialog to keep the leave flow one-tap; proposed a follow-up (#46) for an optional successor picker. - API contract for /leave is unchanged (still POST with no body), so no openapi.yaml or codegen changes were needed. - Pre-existing TypeScript errors in auth.ts, admin.tsx, etc. are unrelated codegen drift and were left alone. Replit-Task-Id: 026e59a5-90c0-4faa-9080-aee0aee0a631 --- .../api-server/src/routes/conversations.ts | 61 ++++++++++++++++++- artifacts/teaboy-os/src/locales/ar.json | 5 +- artifacts/teaboy-os/src/locales/en.json | 5 +- artifacts/teaboy-os/src/pages/chat.tsx | 13 ++++ 4 files changed, 80 insertions(+), 4 deletions(-) diff --git a/artifacts/api-server/src/routes/conversations.ts b/artifacts/api-server/src/routes/conversations.ts index f9113216..5a41ac6a 100644 --- a/artifacts/api-server/src/routes/conversations.ts +++ b/artifacts/api-server/src/routes/conversations.ts @@ -1,5 +1,5 @@ import { Router, type IRouter } from "express"; -import { eq, and, desc, sql } from "drizzle-orm"; +import { eq, and, desc, asc, sql } from "drizzle-orm"; import { db } from "@workspace/db"; import { conversationsTable, @@ -645,11 +645,68 @@ router.post( return; } - if (!(await isConversationMember(params.data.id, userId))) { + const participants = await db + .select({ + userId: conversationParticipantsTable.userId, + isAdmin: conversationParticipantsTable.isAdmin, + joinedAt: conversationParticipantsTable.joinedAt, + }) + .from(conversationParticipantsTable) + .where(eq(conversationParticipantsTable.conversationId, params.data.id)) + .orderBy(asc(conversationParticipantsTable.joinedAt)); + + const leaver = participants.find((p) => p.userId === userId); + if (!leaver) { res.status(403).json({ error: "Forbidden" }); return; } + const remaining = participants.filter((p) => p.userId !== userId); + + // If leaver was the only participant, clean up the conversation entirely. + if (remaining.length === 0) { + await db + .delete(conversationsTable) + .where(eq(conversationsTable.id, params.data.id)); + await evictUserFromConversationRoom(userId, params.data.id); + res.json({ success: true }); + return; + } + + // Determine if we need to promote a successor admin. + const leaverWasOnlyAdmin = + leaver.isAdmin && !remaining.some((p) => p.isAdmin); + const successor = leaverWasOnlyAdmin ? remaining[0] : null; + + if (successor) { + await db + .update(conversationParticipantsTable) + .set({ isAdmin: true }) + .where( + and( + eq(conversationParticipantsTable.conversationId, params.data.id), + eq(conversationParticipantsTable.userId, successor.userId), + ), + ); + } + + // Announce the leave (and promotion) before removing the leaver so the + // system message has a sensible actor and members see it in real time. + const actor = await getUserDisplay(userId); + await insertAndEmitSystemMessage(params.data.id, userId, "member_left", { + actor, + }); + + if (successor) { + const promoted = await getUserDisplay(successor.userId); + await insertAndEmitSystemMessage( + params.data.id, + successor.userId, + "admin_promoted", + { promoted, reason: "previous_admin_left" }, + ); + } + await db .delete(conversationParticipantsTable) .where( diff --git a/artifacts/teaboy-os/src/locales/ar.json b/artifacts/teaboy-os/src/locales/ar.json index d2cfd102..90a990c0 100644 --- a/artifacts/teaboy-os/src/locales/ar.json +++ b/artifacts/teaboy-os/src/locales/ar.json @@ -188,7 +188,10 @@ "groupRenamed": "غيّر {{actor}} اسم المجموعة إلى \"{{name}}\"", "membersAdded_one": "أضاف {{actor}} {{names}}", "membersAdded_other": "أضاف {{actor}} {{names}}", - "memberRemoved": "أزال {{actor}} {{target}}" + "memberRemoved": "أزال {{actor}} {{target}}", + "memberLeft": "غادر {{actor}} المجموعة", + "adminPromoted": "{{target}} أصبح مشرفًا للمجموعة", + "adminPromotedAfterLeave": "تمت ترقية {{target}} إلى مشرف بعد مغادرة المشرف السابق" } }, "notifications": { diff --git a/artifacts/teaboy-os/src/locales/en.json b/artifacts/teaboy-os/src/locales/en.json index 9242c53b..fbb87519 100644 --- a/artifacts/teaboy-os/src/locales/en.json +++ b/artifacts/teaboy-os/src/locales/en.json @@ -185,7 +185,10 @@ "groupRenamed": "{{actor}} renamed the group to \"{{name}}\"", "membersAdded_one": "{{actor}} added {{names}}", "membersAdded_other": "{{actor}} added {{names}}", - "memberRemoved": "{{actor}} removed {{target}}" + "memberRemoved": "{{actor}} removed {{target}}", + "memberLeft": "{{actor}} left the group", + "adminPromoted": "{{target}} is now a group admin", + "adminPromotedAfterLeave": "{{target}} was promoted to admin after the previous admin left" } }, "notifications": { diff --git a/artifacts/teaboy-os/src/pages/chat.tsx b/artifacts/teaboy-os/src/pages/chat.tsx index d94607b7..f8bbbcda 100644 --- a/artifacts/teaboy-os/src/pages/chat.tsx +++ b/artifacts/teaboy-os/src/pages/chat.tsx @@ -70,6 +70,8 @@ type SystemMessageMeta = { newNameEn?: string | null; addedUsers?: UserDisplay[]; removedUser?: UserDisplay | null; + promoted?: UserDisplay | null; + reason?: string | null; }; type SystemRenderable = { @@ -106,6 +108,17 @@ function renderSystemMessage( const target = pickDisplayName(meta.removedUser, lang) || t("chat.system.someone"); return t("chat.system.memberRemoved", { actor, target }); } + case "member_left": { + return t("chat.system.memberLeft", { actor }); + } + case "admin_promoted": { + const target = pickDisplayName(meta.promoted, lang) || t("chat.system.someone"); + const key = + meta.reason === "previous_admin_left" + ? "chat.system.adminPromotedAfterLeave" + : "chat.system.adminPromoted"; + return t(key, { target }); + } default: return ""; }