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:
@@ -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 |
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user