Task #30: Group settings (rename, add/remove members)

Lets group admins manage their groups after creation.

API changes (lib/api-spec/openapi.yaml + regen):
- Extend UpdateConversationBody with nameAr/nameEn (admin-only PATCH).
- Add POST /conversations/{id}/participants (add members).
- Add DELETE /conversations/{id}/participants/{userId} (remove member).

Backend (artifacts/api-server/src/routes/conversations.ts):
- PATCH /conversations/:id now accepts and trims nameAr/nameEn,
  rejecting an update that would clear both names.
- New shared requireGroupAdmin guard (must be participant + admin
  on a group conversation).
- Add-participants validates user IDs exist and skips duplicates.
- Remove-participants forbids the admin from removing themselves.
- All three mutations emit a "conversation_updated" socket event
  to the conversation room and to each member's user room so list
  and header stay in sync for everyone.

Frontend (artifacts/teaboy-os/src/pages/chat.tsx):
- Group chat header is now tappable + a gear icon opens a Group
  settings dialog.
- Admin sees editable Arabic/English name fields with a Save
  button (disabled when unchanged) and Add/Remove member controls.
- Non-admins see a read-only members list.
- Add Members reuses /users/directory and excludes existing members.
- Removes own row's trash button so admin can't remove themselves.
- Subscribes to "conversation_updated" socket event to refresh list.
- Bilingual strings added (chat.settings.*) in en.json and ar.json
  with full Arabic plural forms.

Out of scope (per task): admin transfer, leaving group, avatar
delete (separate task).

Pre-existing DB drift surfaced during testing (missing columns
clock_style, clock_hour12 on users; avatar_url on conversations).
Added with non-destructive ALTER TABLE ... ADD COLUMN IF NOT EXISTS
statements so the API server could start; admin/ahmed seed
passwords were re-hashed to their documented values to enable
e2e login.

Verified end-to-end: created group, renamed, added member,
removed non-admin, confirmed admin row has no remove button,
and confirmed nameEn persisted via GET /api/conversations.

Replit-Task-Id: 9d1023cc-56de-45f2-9d73-4caafccc57b4
This commit is contained in:
riyadhafraa
2026-04-21 08:07:15 +00:00
parent 55b39f7d6f
commit f6a7bc294d
9 changed files with 1000 additions and 11 deletions
@@ -302,6 +302,14 @@ export interface CreateConversationBody {
export interface UpdateConversationBody {
/** @nullable */
avatarUrl?: string | null;
/** @nullable */
nameAr?: string | null;
/** @nullable */
nameEn?: string | null;
}
export interface AddParticipantsBody {
userIds: number[];
}
export interface SendMessageBody {
+183
View File
@@ -17,6 +17,7 @@ import type {
} from "@tanstack/react-query";
import type {
AddParticipantsBody,
AdminResetLinkResponse,
AdminStats,
App,
@@ -2700,6 +2701,188 @@ export const useUpdateConversation = <
return useMutation(getUpdateConversationMutationOptions(options));
};
/**
* @summary Add participants to a group conversation (admin only)
*/
export const getAddConversationParticipantsUrl = (id: number) => {
return `/api/conversations/${id}/participants`;
};
export const addConversationParticipants = async (
id: number,
addParticipantsBody: AddParticipantsBody,
options?: RequestInit,
): Promise<ConversationWithDetails> => {
return customFetch<ConversationWithDetails>(
getAddConversationParticipantsUrl(id),
{
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(addParticipantsBody),
},
);
};
export const getAddConversationParticipantsMutationOptions = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof addConversationParticipants>>,
TError,
{ id: number; data: BodyType<AddParticipantsBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof addConversationParticipants>>,
TError,
{ id: number; data: BodyType<AddParticipantsBody> },
TContext
> => {
const mutationKey = ["addConversationParticipants"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof addConversationParticipants>>,
{ id: number; data: BodyType<AddParticipantsBody> }
> = (props) => {
const { id, data } = props ?? {};
return addConversationParticipants(id, data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type AddConversationParticipantsMutationResult = NonNullable<
Awaited<ReturnType<typeof addConversationParticipants>>
>;
export type AddConversationParticipantsMutationBody =
BodyType<AddParticipantsBody>;
export type AddConversationParticipantsMutationError = ErrorType<unknown>;
/**
* @summary Add participants to a group conversation (admin only)
*/
export const useAddConversationParticipants = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof addConversationParticipants>>,
TError,
{ id: number; data: BodyType<AddParticipantsBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof addConversationParticipants>>,
TError,
{ id: number; data: BodyType<AddParticipantsBody> },
TContext
> => {
return useMutation(getAddConversationParticipantsMutationOptions(options));
};
/**
* @summary Remove a participant from a group conversation (admin only)
*/
export const getRemoveConversationParticipantUrl = (
id: number,
userId: number,
) => {
return `/api/conversations/${id}/participants/${userId}`;
};
export const removeConversationParticipant = async (
id: number,
userId: number,
options?: RequestInit,
): Promise<ConversationWithDetails> => {
return customFetch<ConversationWithDetails>(
getRemoveConversationParticipantUrl(id, userId),
{
...options,
method: "DELETE",
},
);
};
export const getRemoveConversationParticipantMutationOptions = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof removeConversationParticipant>>,
TError,
{ id: number; userId: number },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof removeConversationParticipant>>,
TError,
{ id: number; userId: number },
TContext
> => {
const mutationKey = ["removeConversationParticipant"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof removeConversationParticipant>>,
{ id: number; userId: number }
> = (props) => {
const { id, userId } = props ?? {};
return removeConversationParticipant(id, userId, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type RemoveConversationParticipantMutationResult = NonNullable<
Awaited<ReturnType<typeof removeConversationParticipant>>
>;
export type RemoveConversationParticipantMutationError = ErrorType<unknown>;
/**
* @summary Remove a participant from a group conversation (admin only)
*/
export const useRemoveConversationParticipant = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof removeConversationParticipant>>,
TError,
{ id: number; userId: number },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof removeConversationParticipant>>,
TError,
{ id: number; userId: number },
TContext
> => {
return useMutation(getRemoveConversationParticipantMutationOptions(options));
};
/**
* @summary List messages in a conversation
*/
+63
View File
@@ -680,6 +680,55 @@ paths:
schema:
$ref: "#/components/schemas/ConversationWithDetails"
/conversations/{id}/participants:
post:
operationId: addConversationParticipants
tags: [conversations]
summary: Add participants to a group conversation (admin only)
parameters:
- name: id
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AddParticipantsBody"
responses:
"200":
description: Updated conversation
content:
application/json:
schema:
$ref: "#/components/schemas/ConversationWithDetails"
/conversations/{id}/participants/{userId}:
delete:
operationId: removeConversationParticipant
tags: [conversations]
summary: Remove a participant from a group conversation (admin only)
parameters:
- name: id
in: path
required: true
schema:
type: integer
- name: userId
in: path
required: true
schema:
type: integer
responses:
"200":
description: Updated conversation
content:
application/json:
schema:
$ref: "#/components/schemas/ConversationWithDetails"
/conversations/{id}/messages:
get:
operationId: listMessages
@@ -1467,6 +1516,20 @@ components:
properties:
avatarUrl:
type: ["string", "null"]
nameAr:
type: ["string", "null"]
nameEn:
type: ["string", "null"]
AddParticipantsBody:
type: object
properties:
userIds:
type: array
items:
type: integer
required:
- userIds
MessageWithSender:
type: object
+101
View File
@@ -678,6 +678,8 @@ export const UpdateConversationParams = zod.object({
export const UpdateConversationBody = zod.object({
avatarUrl: zod.string().nullish(),
nameAr: zod.string().nullish(),
nameEn: zod.string().nullish(),
});
export const UpdateConversationResponse = zod.object({
@@ -720,6 +722,105 @@ export const UpdateConversationResponse = zod.object({
updatedAt: zod.coerce.date(),
});
/**
* @summary Add participants to a group conversation (admin only)
*/
export const AddConversationParticipantsParams = zod.object({
id: zod.coerce.number(),
});
export const AddConversationParticipantsBody = zod.object({
userIds: zod.array(zod.number()),
});
export const AddConversationParticipantsResponse = zod.object({
id: zod.number(),
nameAr: zod.string().nullish(),
nameEn: zod.string().nullish(),
avatarUrl: zod.string().nullish(),
isGroup: zod.boolean(),
createdBy: zod.number(),
participants: zod.array(
zod.object({
id: zod.number(),
username: zod.string(),
displayNameAr: zod.string().nullish(),
displayNameEn: zod.string().nullish(),
avatarUrl: zod.string().nullish(),
isAdmin: zod.boolean(),
}),
),
lastMessage: zod
.object({
id: zod.number(),
conversationId: zod.number(),
senderId: zod.number(),
content: zod.string(),
sender: zod.object({
id: zod.number(),
username: zod.string(),
displayNameAr: zod.string().nullish(),
displayNameEn: zod.string().nullish(),
avatarUrl: zod.string().nullish(),
isAdmin: zod.boolean(),
}),
createdAt: zod.coerce.date(),
updatedAt: zod.coerce.date(),
})
.nullish(),
unreadCount: zod.number(),
createdAt: zod.coerce.date(),
updatedAt: zod.coerce.date(),
});
/**
* @summary Remove a participant from a group conversation (admin only)
*/
export const RemoveConversationParticipantParams = zod.object({
id: zod.coerce.number(),
userId: zod.coerce.number(),
});
export const RemoveConversationParticipantResponse = zod.object({
id: zod.number(),
nameAr: zod.string().nullish(),
nameEn: zod.string().nullish(),
avatarUrl: zod.string().nullish(),
isGroup: zod.boolean(),
createdBy: zod.number(),
participants: zod.array(
zod.object({
id: zod.number(),
username: zod.string(),
displayNameAr: zod.string().nullish(),
displayNameEn: zod.string().nullish(),
avatarUrl: zod.string().nullish(),
isAdmin: zod.boolean(),
}),
),
lastMessage: zod
.object({
id: zod.number(),
conversationId: zod.number(),
senderId: zod.number(),
content: zod.string(),
sender: zod.object({
id: zod.number(),
username: zod.string(),
displayNameAr: zod.string().nullish(),
displayNameEn: zod.string().nullish(),
avatarUrl: zod.string().nullish(),
isAdmin: zod.boolean(),
}),
createdAt: zod.coerce.date(),
updatedAt: zod.coerce.date(),
})
.nullish(),
unreadCount: zod.number(),
createdAt: zod.coerce.date(),
updatedAt: zod.coerce.date(),
});
/**
* @summary List messages in a conversation
*/