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 ""; }