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
This commit is contained in:
riyadhafraa
2026-04-21 09:44:00 +00:00
parent 5a0f6803b0
commit a52c1659fc
4 changed files with 80 additions and 4 deletions
@@ -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(
+4 -1
View File
@@ -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": {
+4 -1
View File
@@ -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": {
+13
View File
@@ -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 "";
}