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:
@@ -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