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
@@ -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()),
});