From db9cf7b3152b6a637892377f710d28992936d30f Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Tue, 21 Apr 2026 09:35:32 +0000 Subject: [PATCH] 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 --- .../api-server/src/routes/conversations.ts | 114 +++++++++++++++++- artifacts/teaboy-os/public/opengraph.jpg | Bin 24523 -> 25867 bytes artifacts/teaboy-os/src/locales/ar.json | 8 ++ artifacts/teaboy-os/src/locales/en.json | 8 ++ artifacts/teaboy-os/src/pages/chat.tsx | 75 +++++++++++- .../src/generated/api.schemas.ts | 14 +++ lib/api-spec/openapi.yaml | 8 ++ lib/api-zod/src/generated/api.ts | 44 +++++++ lib/db/src/schema/conversations.ts | 4 +- 9 files changed, 271 insertions(+), 4 deletions(-) diff --git a/artifacts/api-server/src/routes/conversations.ts b/artifacts/api-server/src/routes/conversations.ts index c8850b57..06843cb7 100644 --- a/artifacts/api-server/src/routes/conversations.ts +++ b/artifacts/api-server/src/routes/conversations.ts @@ -284,6 +284,10 @@ router.patch("/conversations/:id", requireAuth, async (req, res): Promise 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 .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; + +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 { + 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`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 { 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" }); diff --git a/artifacts/teaboy-os/public/opengraph.jpg b/artifacts/teaboy-os/public/opengraph.jpg index 4d6d4b26ea9384bec22c58c3b54140a012d22f2d..8c2ac7c47030b29fdad680e116b78f18323d4fe9 100644 GIT binary patch literal 25867 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 E1FC~LDF6Tf diff --git a/artifacts/teaboy-os/src/locales/ar.json b/artifacts/teaboy-os/src/locales/ar.json index 011b1ac5..d2cfd102 100644 --- a/artifacts/teaboy-os/src/locales/ar.json +++ b/artifacts/teaboy-os/src/locales/ar.json @@ -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": { diff --git a/artifacts/teaboy-os/src/locales/en.json b/artifacts/teaboy-os/src/locales/en.json index 63ebfa8c..9242c53b 100644 --- a/artifacts/teaboy-os/src/locales/en.json +++ b/artifacts/teaboy-os/src/locales/en.json @@ -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": { diff --git a/artifacts/teaboy-os/src/pages/chat.tsx b/artifacts/teaboy-os/src/pages/chat.tsx index 63450849..d94607b7 100644 --- a/artifacts/teaboy-os/src/pages/chat.tsx +++ b/artifacts/teaboy-os/src/pages/chat.tsx @@ -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, +): 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() {
{messages?.map((msg) => { + if (msg.kind && msg.kind !== "user") { + const text = renderSystemMessage(msg, lang, t); + return ( +
+
+ {text} +
+
+ ); + } const isMe = msg.senderId === user?.id; const senderName = (lang === "ar" ? msg.sender?.displayNameAr : msg.sender?.displayNameEn) ?? @@ -1426,7 +1497,9 @@ export default function ChatPage() {
{lastMsg && (

- {lastMsg.content} + {lastMsg.kind && lastMsg.kind !== "user" + ? renderSystemMessage(lastMsg, lang, t) + : lastMsg.content}

)} diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index aff37058..d1b6310a 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -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; diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 781ff744..a0694733 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -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 diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index b7a22233..089a835d 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -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(), diff --git a/lib/db/src/schema/conversations.ts b/lib/db/src/schema/conversations.ts index a36d96c2..46db5ef6 100644 --- a/lib/db/src/schema/conversations.ts +++ b/lib/db/src/schema/conversations.ts @@ -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()), });