Send system messages for group rename / add / remove

Original task (#38): When admins rename a group or add/remove members,
post a system message into the chat thread so other members see who
changed what and when, with bilingual (AR/EN) text and real-time
delivery.

Changes
- lib/db/src/schema/conversations.ts: added `kind` (varchar default
  "user") and `meta` (jsonb) columns on the `messages` table. Pushed
  the new columns directly via ALTER TABLE since drizzle-kit push
  prompted on unrelated rename ambiguities. Also healed pre-existing
  schema drift on `users.clock_hour12`,
  `conversations.avatar_url`, and
  `conversation_participants.is_muted/is_archived` so the API could
  start.
- lib/api-spec/openapi.yaml: extended MessageWithSender with `kind`
  (enum: user | group_renamed | members_added | member_removed) and
  optional `meta`. Re-ran codegen.
- artifacts/api-server/src/routes/conversations.ts: added
  insertAndEmitSystemMessage + small user-display helpers; PATCH
  conversation now emits a `group_renamed` system message when a
  name actually changes; add-participants emits `members_added` with
  the actor + added users; remove-participant emits `member_removed`
  with actor + removed user. Each system message is broadcast over
  Socket.IO via the existing `new_message` channel so all current
  members receive it immediately.
- artifacts/teaboy-os/src/pages/chat.tsx: render messages with
  `kind != "user"` as centered, muted pill bubbles (no avatar /
  sender label) using a new renderSystemMessage helper that picks
  the language-appropriate name out of meta. Conversation list
  preview also uses it so the last activity reads sensibly when the
  most recent message is a system message.
- artifacts/teaboy-os/src/locales/{en,ar}.json: added chat.system.*
  strings (groupRenamed, membersAdded with plural variants,
  memberRemoved, someone, listSeparator).

Verification
- Typecheck (libs + artifacts) passes.
- e2e via testing skill: registered fresh users, created a group,
  renamed it, added a member, removed a member; all three centered
  system messages appeared in the thread in order with the expected
  copy.

Notes / deviations
- Used the actor's userId as senderId for system messages (kept
  existing NOT NULL FK) instead of introducing a nullable sender,
  which keeps the migration lightweight. This means system messages
  count toward unread for non-actor members; flagged as a follow-up.

Replit-Task-Id: 6dfa2b99-fbac-4146-b59b-8c04a14c9e96
This commit is contained in:
riyadhafraa
2026-04-21 09:35:32 +00:00
parent c1b53dea8d
commit db9cf7b315
9 changed files with 271 additions and 4 deletions
@@ -284,6 +284,10 @@ router.patch("/conversations/:id", requireAuth, async (req, res): Promise<void>
return;
}
const nameChanged =
(updates.nameAr !== undefined && updates.nameAr !== conv.nameAr) ||
(updates.nameEn !== undefined && updates.nameEn !== conv.nameEn);
if (Object.keys(updates).length > 0) {
await db
.update(conversationsTable)
@@ -291,6 +295,17 @@ router.patch("/conversations/:id", requireAuth, async (req, res): Promise<void>
.where(eq(conversationsTable.id, params.data.id));
}
if (nameChanged) {
const actor = await getUserDisplay(userId);
await insertAndEmitSystemMessage(params.data.id, userId, "group_renamed", {
actor,
oldNameAr: conv.nameAr,
oldNameEn: conv.nameEn,
newNameAr: finalNameAr,
newNameEn: finalNameEn,
});
}
const details = await buildConversationDetails(params.data.id, userId);
if (!details) {
res.status(404).json({ error: "Conversation not found" });
@@ -313,6 +328,80 @@ async function evictUserFromConversationRoom(
}
}
type SystemMessageMeta = Record<string, unknown>;
async function getUserDisplay(userId: number): Promise<{
id: number;
username: string;
displayNameAr: string | null;
displayNameEn: string | null;
} | null> {
const [u] = await db
.select({
id: usersTable.id,
username: usersTable.username,
displayNameAr: usersTable.displayNameAr,
displayNameEn: usersTable.displayNameEn,
})
.from(usersTable)
.where(eq(usersTable.id, userId));
return u ?? null;
}
async function getUsersDisplay(userIds: number[]): Promise<
Array<{
id: number;
username: string;
displayNameAr: string | null;
displayNameEn: string | null;
}>
> {
if (userIds.length === 0) return [];
return db
.select({
id: usersTable.id,
username: usersTable.username,
displayNameAr: usersTable.displayNameAr,
displayNameEn: usersTable.displayNameEn,
})
.from(usersTable)
.where(sql`${usersTable.id} IN (${sql.join(userIds.map((id) => sql`${id}`), sql`, `)})`);
}
async function insertAndEmitSystemMessage(
conversationId: number,
actorId: number,
kind: string,
meta: SystemMessageMeta,
): Promise<void> {
const [msg] = await db
.insert(messagesTable)
.values({
conversationId,
senderId: actorId,
content: "",
kind,
meta,
})
.returning();
const [sender] = await db
.select({
id: usersTable.id,
username: usersTable.username,
displayNameAr: usersTable.displayNameAr,
displayNameEn: usersTable.displayNameEn,
avatarUrl: usersTable.avatarUrl,
isAdmin: sql<boolean>`false`,
})
.from(usersTable)
.where(eq(usersTable.id, actorId));
const fullMessage = { ...msg, sender };
const { io } = await import("../index.js");
io.to(`conversation:${conversationId}`).emit("new_message", fullMessage);
}
async function emitConversationUpdate(conversationId: number): Promise<void> {
const { io } = await import("../index.js");
const members = await db
@@ -410,6 +499,15 @@ router.post(
})),
)
.onConflictDoNothing();
const [actor, addedUsers] = await Promise.all([
getUserDisplay(userId),
getUsersDisplay(validIds),
]);
await insertAndEmitSystemMessage(params.data.id, userId, "members_added", {
actor,
addedUsers,
});
}
const details = await buildConversationDetails(params.data.id, userId);
@@ -443,17 +541,29 @@ router.delete(
return;
}
await db
const deleted = await db
.delete(conversationParticipantsTable)
.where(
and(
eq(conversationParticipantsTable.conversationId, params.data.id),
eq(conversationParticipantsTable.userId, params.data.userId),
),
);
)
.returning({ userId: conversationParticipantsTable.userId });
await evictUserFromConversationRoom(params.data.userId, params.data.id);
if (deleted.length > 0) {
const [actor, removedUser] = await Promise.all([
getUserDisplay(userId),
getUserDisplay(params.data.userId),
]);
await insertAndEmitSystemMessage(params.data.id, userId, "member_removed", {
actor,
removedUser,
});
}
const details = await buildConversationDetails(params.data.id, userId);
if (!details) {
res.status(404).json({ error: "Conversation not found" });