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.
This commit is contained in:
Riyadh
2026-04-21 09:35:32 +00:00
parent 00d07e02a8
commit ebc2e977b0
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" });
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+8
View File
@@ -181,6 +181,14 @@
"left": "غادرت المجموعة",
"actionFailed": "تعذّر تنفيذ الإجراء",
"mutedLabel": "مكتومة"
},
"system": {
"someone": "شخص ما",
"listSeparator": "، ",
"groupRenamed": "غيّر {{actor}} اسم المجموعة إلى \"{{name}}\"",
"membersAdded_one": "أضاف {{actor}} {{names}}",
"membersAdded_other": "أضاف {{actor}} {{names}}",
"memberRemoved": "أزال {{actor}} {{target}}"
}
},
"notifications": {
+8
View File
@@ -178,6 +178,14 @@
"left": "You left the group",
"actionFailed": "Could not complete action",
"mutedLabel": "Muted"
},
"system": {
"someone": "Someone",
"listSeparator": ", ",
"groupRenamed": "{{actor}} renamed the group to \"{{name}}\"",
"membersAdded_one": "{{actor}} added {{names}}",
"membersAdded_other": "{{actor}} added {{names}}",
"memberRemoved": "{{actor}} removed {{target}}"
}
},
"notifications": {
+74 -1
View File
@@ -50,6 +50,67 @@ import { Camera, Loader2, X } from "lucide-react";
const BASE = import.meta.env.BASE_URL.replace(/\/$/, "");
type UserDisplay = {
id: number;
username: string;
displayNameAr?: string | null;
displayNameEn?: string | null;
};
function pickDisplayName(u: UserDisplay | null | undefined, lang: string): string {
if (!u) return "";
return (lang === "ar" ? u.displayNameAr : u.displayNameEn) ?? u.username;
}
type SystemMessageMeta = {
actor?: UserDisplay | null;
oldNameAr?: string | null;
oldNameEn?: string | null;
newNameAr?: string | null;
newNameEn?: string | null;
addedUsers?: UserDisplay[];
removedUser?: UserDisplay | null;
};
type SystemRenderable = {
kind?: string | null;
meta?: unknown;
};
function renderSystemMessage(
msg: SystemRenderable,
lang: string,
t: (k: string, opts?: Record<string, unknown>) => string,
): string {
const meta = (msg.meta ?? {}) as SystemMessageMeta;
const actor = pickDisplayName(meta.actor, lang) || t("chat.system.someone");
switch (msg.kind) {
case "group_renamed": {
const newName =
(lang === "ar" ? meta.newNameAr : meta.newNameEn) ??
(lang === "ar" ? meta.newNameEn : meta.newNameAr) ??
"";
return t("chat.system.groupRenamed", { actor, name: newName });
}
case "members_added": {
const names = (meta.addedUsers ?? [])
.map((u) => pickDisplayName(u, lang))
.filter(Boolean);
return t("chat.system.membersAdded", {
actor,
names: names.join(t("chat.system.listSeparator")),
count: names.length,
});
}
case "member_removed": {
const target = pickDisplayName(meta.removedUser, lang) || t("chat.system.someone");
return t("chat.system.memberRemoved", { actor, target });
}
default:
return "";
}
}
function getSocket() {
return io(window.location.origin, {
path: `${BASE}/api/socket.io`,
@@ -1242,6 +1303,16 @@ export default function ChatPage() {
<ScrollArea className="flex-1 p-4">
<div className="space-y-3">
{messages?.map((msg) => {
if (msg.kind && msg.kind !== "user") {
const text = renderSystemMessage(msg, lang, t);
return (
<div key={msg.id} className="flex justify-center" data-testid={`system-message-${msg.kind}`}>
<div className="max-w-md text-center text-xs text-muted-foreground bg-slate-100/70 rounded-full px-3 py-1">
{text}
</div>
</div>
);
}
const isMe = msg.senderId === user?.id;
const senderName =
(lang === "ar" ? msg.sender?.displayNameAr : msg.sender?.displayNameEn) ??
@@ -1426,7 +1497,9 @@ export default function ChatPage() {
</div>
{lastMsg && (
<p className="text-xs text-muted-foreground truncate mt-0.5">
{lastMsg.content}
{lastMsg.kind && lastMsg.kind !== "user"
? renderSystemMessage(lastMsg, lang, t)
: lastMsg.content}
</p>
)}
</div>
@@ -261,11 +261,25 @@ export interface ParticipantInfo {
isAdmin: boolean;
}
export type MessageWithSenderKind =
(typeof MessageWithSenderKind)[keyof typeof MessageWithSenderKind];
export const MessageWithSenderKind = {
user: "user",
group_renamed: "group_renamed",
members_added: "members_added",
member_removed: "member_removed",
} as const;
export type MessageWithSenderMeta = { [key: string]: unknown } | null;
export interface MessageWithSender {
id: number;
conversationId: number;
senderId: number;
content: string;
kind: MessageWithSenderKind;
meta?: MessageWithSenderMeta;
sender: ParticipantInfo;
createdAt: string;
updatedAt: string;
+8
View File
@@ -1606,6 +1606,13 @@ components:
type: integer
content:
type: string
kind:
type: string
enum: [user, group_renamed, members_added, member_removed]
meta:
type: object
nullable: true
additionalProperties: true
sender:
$ref: "#/components/schemas/ParticipantInfo"
createdAt:
@@ -1619,6 +1626,7 @@ components:
- conversationId
- senderId
- content
- kind
- sender
- createdAt
- updatedAt
+44
View File
@@ -591,6 +591,13 @@ export const ListConversationsResponseItem = zod.object({
conversationId: zod.number(),
senderId: zod.number(),
content: zod.string(),
kind: zod.enum([
"user",
"group_renamed",
"members_added",
"member_removed",
]),
meta: zod.record(zod.string(), zod.unknown()).nullish(),
sender: zod.object({
id: zod.number(),
username: zod.string(),
@@ -654,6 +661,13 @@ export const GetConversationResponse = zod.object({
conversationId: zod.number(),
senderId: zod.number(),
content: zod.string(),
kind: zod.enum([
"user",
"group_renamed",
"members_added",
"member_removed",
]),
meta: zod.record(zod.string(), zod.unknown()).nullish(),
sender: zod.object({
id: zod.number(),
username: zod.string(),
@@ -709,6 +723,13 @@ export const UpdateConversationResponse = zod.object({
conversationId: zod.number(),
senderId: zod.number(),
content: zod.string(),
kind: zod.enum([
"user",
"group_renamed",
"members_added",
"member_removed",
]),
meta: zod.record(zod.string(), zod.unknown()).nullish(),
sender: zod.object({
id: zod.number(),
username: zod.string(),
@@ -762,6 +783,13 @@ export const AddConversationParticipantsResponse = zod.object({
conversationId: zod.number(),
senderId: zod.number(),
content: zod.string(),
kind: zod.enum([
"user",
"group_renamed",
"members_added",
"member_removed",
]),
meta: zod.record(zod.string(), zod.unknown()).nullish(),
sender: zod.object({
id: zod.number(),
username: zod.string(),
@@ -812,6 +840,13 @@ export const RemoveConversationParticipantResponse = zod.object({
conversationId: zod.number(),
senderId: zod.number(),
content: zod.string(),
kind: zod.enum([
"user",
"group_renamed",
"members_added",
"member_removed",
]),
meta: zod.record(zod.string(), zod.unknown()).nullish(),
sender: zod.object({
id: zod.number(),
username: zod.string(),
@@ -866,6 +901,13 @@ export const UpdateConversationStateResponse = zod.object({
conversationId: zod.number(),
senderId: zod.number(),
content: zod.string(),
kind: zod.enum([
"user",
"group_renamed",
"members_added",
"member_removed",
]),
meta: zod.record(zod.string(), zod.unknown()).nullish(),
sender: zod.object({
id: zod.number(),
username: zod.string(),
@@ -908,6 +950,8 @@ export const ListMessagesResponseItem = zod.object({
conversationId: zod.number(),
senderId: zod.number(),
content: zod.string(),
kind: zod.enum(["user", "group_renamed", "members_added", "member_removed"]),
meta: zod.record(zod.string(), zod.unknown()).nullish(),
sender: zod.object({
id: zod.number(),
username: zod.string(),
+3 -1
View File
@@ -1,4 +1,4 @@
import { pgTable, text, serial, timestamp, integer, varchar, boolean, primaryKey } from "drizzle-orm/pg-core";
import { pgTable, text, serial, timestamp, integer, varchar, boolean, primaryKey, jsonb } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod/v4";
import { usersTable } from "./users";
@@ -28,6 +28,8 @@ export const messagesTable = pgTable("messages", {
conversationId: integer("conversation_id").notNull().references(() => conversationsTable.id, { onDelete: "cascade" }),
senderId: integer("sender_id").notNull().references(() => usersTable.id),
content: text("content").notNull(),
kind: varchar("kind", { length: 32 }).notNull().default("user"),
meta: jsonb("meta"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()),
});