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