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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user