From f6a7bc294dac5e968f3c67b8d42e4cf950bf8567 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Tue, 21 Apr 2026 08:07:15 +0000 Subject: [PATCH] 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 --- .../api-server/src/routes/conversations.ts | 180 +++++++- artifacts/teaboy-os/public/opengraph.jpg | Bin 25889 -> 25884 bytes artifacts/teaboy-os/src/locales/ar.json | 25 +- artifacts/teaboy-os/src/locales/en.json | 22 +- artifacts/teaboy-os/src/pages/chat.tsx | 429 +++++++++++++++++- .../src/generated/api.schemas.ts | 8 + lib/api-client-react/src/generated/api.ts | 183 ++++++++ lib/api-spec/openapi.yaml | 63 +++ lib/api-zod/src/generated/api.ts | 101 +++++ 9 files changed, 1000 insertions(+), 11 deletions(-) diff --git a/artifacts/api-server/src/routes/conversations.ts b/artifacts/api-server/src/routes/conversations.ts index f3fe4c41..608e5c16 100644 --- a/artifacts/api-server/src/routes/conversations.ts +++ b/artifacts/api-server/src/routes/conversations.ts @@ -18,6 +18,9 @@ import { MarkConversationReadParams, UpdateConversationBody, UpdateConversationParams, + AddConversationParticipantsParams, + AddConversationParticipantsBody, + RemoveConversationParticipantParams, } from "@workspace/api-zod"; const router: IRouter = Router(); @@ -215,7 +218,11 @@ router.patch("/conversations/:id", requireAuth, async (req, res): Promise } const [conv] = await db - .select({ isGroup: conversationsTable.isGroup }) + .select({ + isGroup: conversationsTable.isGroup, + nameAr: conversationsTable.nameAr, + nameEn: conversationsTable.nameEn, + }) .from(conversationsTable) .where(eq(conversationsTable.id, params.data.id)); if (!conv) { @@ -231,6 +238,25 @@ router.patch("/conversations/:id", requireAuth, async (req, res): Promise if (parsed.data.avatarUrl !== undefined) { updates.avatarUrl = parsed.data.avatarUrl; } + if (parsed.data.nameAr !== undefined) { + const trimmed = parsed.data.nameAr?.trim(); + updates.nameAr = trimmed ? trimmed : null; + } + if (parsed.data.nameEn !== undefined) { + const trimmed = parsed.data.nameEn?.trim(); + updates.nameEn = trimmed ? trimmed : null; + } + + const finalNameAr = updates.nameAr !== undefined ? updates.nameAr : conv.nameAr; + const finalNameEn = updates.nameEn !== undefined ? updates.nameEn : conv.nameEn; + if ( + (updates.nameAr !== undefined || updates.nameEn !== undefined) && + !finalNameAr && + !finalNameEn + ) { + res.status(400).json({ error: "Group must have an Arabic or English name" }); + return; + } if (Object.keys(updates).length > 0) { await db @@ -244,9 +270,161 @@ router.patch("/conversations/:id", requireAuth, async (req, res): Promise res.status(404).json({ error: "Conversation not found" }); return; } + + await emitConversationUpdate(params.data.id); + res.json(details); }); +async function emitConversationUpdate(conversationId: number): Promise { + const { io } = await import("../index.js"); + const members = await db + .select({ userId: conversationParticipantsTable.userId }) + .from(conversationParticipantsTable) + .where(eq(conversationParticipantsTable.conversationId, conversationId)); + + io.to(`conversation:${conversationId}`).emit("conversation_updated", { + conversationId, + }); + for (const m of members) { + io.to(`user:${m.userId}`).emit("conversation_updated", { conversationId }); + } +} + +async function requireGroupAdmin( + conversationId: number, + userId: number, +): Promise<{ ok: true } | { ok: false; status: number; error: string }> { + const [participant] = await db + .select({ isAdmin: conversationParticipantsTable.isAdmin }) + .from(conversationParticipantsTable) + .where( + and( + eq(conversationParticipantsTable.conversationId, conversationId), + eq(conversationParticipantsTable.userId, userId), + ), + ); + if (!participant) return { ok: false, status: 403, error: "Forbidden" }; + if (!participant.isAdmin) { + return { ok: false, status: 403, error: "Only group admins can manage participants" }; + } + const [conv] = await db + .select({ isGroup: conversationsTable.isGroup }) + .from(conversationsTable) + .where(eq(conversationsTable.id, conversationId)); + if (!conv) return { ok: false, status: 404, error: "Conversation not found" }; + if (!conv.isGroup) { + return { ok: false, status: 400, error: "Direct conversations cannot be modified" }; + } + return { ok: true }; +} + +router.post( + "/conversations/:id/participants", + requireAuth, + async (req, res): Promise => { + const params = AddConversationParticipantsParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + const parsed = AddConversationParticipantsBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + const userId = req.session.userId!; + const guard = await requireGroupAdmin(params.data.id, userId); + if (!guard.ok) { + res.status(guard.status).json({ error: guard.error }); + return; + } + + const requestedIds = [...new Set(parsed.data.userIds)].filter((id) => Number.isInteger(id)); + if (requestedIds.length === 0) { + res.status(400).json({ error: "No users to add" }); + return; + } + + const existing = await db + .select({ userId: conversationParticipantsTable.userId }) + .from(conversationParticipantsTable) + .where(eq(conversationParticipantsTable.conversationId, params.data.id)); + const existingIds = new Set(existing.map((e) => e.userId)); + const toAdd = requestedIds.filter((id) => !existingIds.has(id)); + + if (toAdd.length > 0) { + const validUsers = await db + .select({ id: usersTable.id }) + .from(usersTable) + .where(sql`${usersTable.id} IN (${sql.join(toAdd.map((id) => sql`${id}`), sql`, `)})`); + const validIds = validUsers.map((u) => u.id); + if (validIds.length === 0) { + res.status(400).json({ error: "No valid users to add" }); + return; + } + await db + .insert(conversationParticipantsTable) + .values( + validIds.map((pid) => ({ + conversationId: params.data.id, + userId: pid, + isAdmin: false, + })), + ) + .onConflictDoNothing(); + } + + const details = await buildConversationDetails(params.data.id, userId); + if (!details) { + res.status(404).json({ error: "Conversation not found" }); + return; + } + await emitConversationUpdate(params.data.id); + res.json(details); + }, +); + +router.delete( + "/conversations/:id/participants/:userId", + requireAuth, + async (req, res): Promise => { + const params = RemoveConversationParticipantParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + const userId = req.session.userId!; + const guard = await requireGroupAdmin(params.data.id, userId); + if (!guard.ok) { + res.status(guard.status).json({ error: guard.error }); + return; + } + + if (params.data.userId === userId) { + res.status(400).json({ error: "Admins cannot remove themselves" }); + return; + } + + await db + .delete(conversationParticipantsTable) + .where( + and( + eq(conversationParticipantsTable.conversationId, params.data.id), + eq(conversationParticipantsTable.userId, params.data.userId), + ), + ); + + const details = await buildConversationDetails(params.data.id, userId); + if (!details) { + res.status(404).json({ error: "Conversation not found" }); + return; + } + await emitConversationUpdate(params.data.id); + res.json(details); + }, +); + router.get("/conversations/:id/messages", requireAuth, async (req, res): Promise => { const params = ListMessagesParams.safeParse(req.params); if (!params.success) { diff --git a/artifacts/teaboy-os/public/opengraph.jpg b/artifacts/teaboy-os/public/opengraph.jpg index eebd020f14ebf495a65414a788d6216d8443816d..5fc27c2a92300a32dbd59b1f8c7ff288773406a4 100644 GIT binary patch literal 25884 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 literal 25889 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 z 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 Ax&QzG diff --git a/artifacts/teaboy-os/src/locales/ar.json b/artifacts/teaboy-os/src/locales/ar.json index 8953d5d4..f6161e91 100644 --- a/artifacts/teaboy-os/src/locales/ar.json +++ b/artifacts/teaboy-os/src/locales/ar.json @@ -137,7 +137,30 @@ "minTwoMembers": "اختر شخصين على الأقل لإنشاء مجموعة.", "pickOnePerson": "اختر شخصاً واحداً للمحادثة." }, - "create": "إنشاء" + "create": "إنشاء", + "settings": { + "title": "إعدادات المجموعة", + "openSettings": "فتح إعدادات المجموعة", + "groupName": "اسم المجموعة", + "saveName": "حفظ الاسم", + "nameSaved": "تم تحديث اسم المجموعة", + "saveFailed": "تعذّر حفظ التغييرات", + "needGroupName": "أدخل اسم المجموعة بالعربية أو الإنجليزية.", + "members_one": "عضو واحد", + "members_two": "عضوان", + "members_few": "{{count}} أعضاء", + "members_many": "{{count}} عضواً", + "members_other": "{{count}} عضو", + "addMembers": "إضافة أعضاء", + "addSelected": "إضافة", + "membersAdded": "تمت إضافة الأعضاء", + "addFailed": "تعذّرت إضافة الأعضاء", + "removeMember": "إزالة العضو", + "removeFailed": "تعذّرت إزالة العضو", + "admin": "مشرف", + "you": "(أنت)", + "viewOnly": "فقط مشرف المجموعة يستطيع تغيير الاسم أو الأعضاء." + } }, "notifications": { "title": "الإشعارات", diff --git a/artifacts/teaboy-os/src/locales/en.json b/artifacts/teaboy-os/src/locales/en.json index 8f8eec90..51afa9ba 100644 --- a/artifacts/teaboy-os/src/locales/en.json +++ b/artifacts/teaboy-os/src/locales/en.json @@ -137,7 +137,27 @@ "minTwoMembers": "Pick at least 2 people for a group.", "pickOnePerson": "Pick one person to chat with." }, - "create": "Create" + "create": "Create", + "settings": { + "title": "Group settings", + "openSettings": "Open group settings", + "groupName": "Group name", + "saveName": "Save name", + "nameSaved": "Group name updated", + "saveFailed": "Could not save changes", + "needGroupName": "Enter a group name in Arabic or English.", + "members_one": "{{count}} member", + "members_other": "{{count}} members", + "addMembers": "Add members", + "addSelected": "Add", + "membersAdded": "Members added", + "addFailed": "Could not add members", + "removeMember": "Remove member", + "removeFailed": "Could not remove member", + "admin": "Admin", + "you": "(you)", + "viewOnly": "Only the group admin can change the name or members." + } }, "notifications": { "title": "Notifications", diff --git a/artifacts/teaboy-os/src/pages/chat.tsx b/artifacts/teaboy-os/src/pages/chat.tsx index 93eb1abd..e13f97f6 100644 --- a/artifacts/teaboy-os/src/pages/chat.tsx +++ b/artifacts/teaboy-os/src/pages/chat.tsx @@ -10,6 +10,8 @@ import { useMarkConversationRead, useCreateConversation, useUpdateConversation, + useAddConversationParticipants, + useRemoveConversationParticipant, } from "@workspace/api-client-react"; import { useUpload, type UploadResponse } from "@workspace/object-storage-web"; import { resolveServiceImageUrl } from "@/lib/image-url"; @@ -26,6 +28,9 @@ import { Search, Users, User as UserIcon, + Settings, + UserPlus, + Trash2, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -63,6 +68,12 @@ export default function ChatPage() { const [groupNameEn, setGroupNameEn] = useState(""); const [groupAvatarUrl, setGroupAvatarUrl] = useState(null); const [userSearch, setUserSearch] = useState(""); + const [showSettings, setShowSettings] = useState(false); + const [showAddMembers, setShowAddMembers] = useState(false); + const [editNameAr, setEditNameAr] = useState(""); + const [editNameEn, setEditNameEn] = useState(""); + const [addMembersIds, setAddMembersIds] = useState([]); + const [addMembersSearch, setAddMembersSearch] = useState(""); const bottomRef = useRef(null); const { toast } = useToast(); @@ -93,7 +104,7 @@ export default function ChatPage() { queryKey: ["users-directory"], queryFn: () => fetch("/api/users/directory", { credentials: "include" }).then((r) => r.json()), - enabled: showNewConv, + enabled: showNewConv || showAddMembers, }); const sendMessage = useSendMessage(); @@ -123,6 +134,10 @@ export default function ChatPage() { } }); + socket.on("conversation_updated", () => { + queryClient.invalidateQueries({ queryKey: getListConversationsQueryKey() }); + }); + return () => { if (selectedConvId) { socket.emit("leave_conversation", selectedConvId); @@ -276,6 +291,119 @@ export default function ChatPage() { ); const updateConv = useUpdateConversation(); + const addParticipants = useAddConversationParticipants(); + const removeParticipant = useRemoveConversationParticipant(); + + const openSettings = () => { + if (!selectedConv) return; + setEditNameAr(selectedConv.nameAr ?? ""); + setEditNameEn(selectedConv.nameEn ?? ""); + setShowSettings(true); + }; + + const closeSettings = () => { + setShowSettings(false); + setShowAddMembers(false); + setAddMembersIds([]); + setAddMembersSearch(""); + }; + + const handleSaveName = () => { + if (!selectedConvId) return; + const ar = editNameAr.trim(); + const en = editNameEn.trim(); + if (!ar && !en) { + toast({ + title: t("chat.settings.needGroupName"), + variant: "destructive", + }); + return; + } + updateConv.mutate( + { id: selectedConvId, data: { nameAr: ar || null, nameEn: en || null } }, + { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: getListConversationsQueryKey() }); + toast({ title: t("chat.settings.nameSaved") }); + }, + onError: (err: Error) => { + toast({ + title: t("chat.settings.saveFailed"), + description: err.message, + variant: "destructive", + }); + }, + }, + ); + }; + + const handleAddMembers = () => { + if (!selectedConvId || addMembersIds.length === 0) return; + addParticipants.mutate( + { id: selectedConvId, data: { userIds: addMembersIds } }, + { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: getListConversationsQueryKey() }); + setAddMembersIds([]); + setAddMembersSearch(""); + setShowAddMembers(false); + toast({ title: t("chat.settings.membersAdded") }); + }, + onError: (err: Error) => { + toast({ + title: t("chat.settings.addFailed"), + description: err.message, + variant: "destructive", + }); + }, + }, + ); + }; + + const handleRemoveMember = (memberId: number) => { + if (!selectedConvId) return; + removeParticipant.mutate( + { id: selectedConvId, userId: memberId }, + { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: getListConversationsQueryKey() }); + }, + onError: (err: Error) => { + toast({ + title: t("chat.settings.removeFailed"), + description: err.message, + variant: "destructive", + }); + }, + }, + ); + }; + + const existingMemberIds = useMemo( + () => new Set(selectedConv?.participants?.map((p) => p.id) ?? []), + [selectedConv], + ); + const addableUsers = useMemo(() => { + const list = (users ?? []).filter((u) => !existingMemberIds.has(u.id)); + const q = addMembersSearch.trim().toLowerCase(); + if (!q) return list; + return list.filter((u) => { + const fields = [u.username, u.displayNameAr ?? "", u.displayNameEn ?? ""]; + return fields.some((f) => f.toLowerCase().includes(q)); + }); + }, [users, existingMemberIds, addMembersSearch]); + + const toggleAddMember = (uid: number) => { + setAddMembersIds((prev) => + prev.includes(uid) ? prev.filter((id) => id !== uid) : [...prev, uid], + ); + }; + + const nameDirty = + !!selectedConv && + selectedConv.isGroup && + (editNameAr.trim() !== (selectedConv.nameAr ?? "") || + editNameEn.trim() !== (selectedConv.nameEn ?? "")); const { uploadFile: uploadHeaderAvatar, isUploading: isUploadingHeaderAvatar } = useUpload({ onSuccess: (resp: UploadResponse) => { if (!selectedConvId) return; @@ -355,15 +483,32 @@ export default function ChatPage() { ) : ( )} -

- {selectedConvId ? convName : t("chat.title")} -

- {selectedConvId && selectedConv?.isGroup && ( - - {t("chat.groupChat")} - + {selectedConvId && selectedConv?.isGroup ? ( + + ) : ( +

+ {selectedConvId ? convName : t("chat.title")} +

)} + {selectedConvId && selectedConv?.isGroup && ( + + )} {!selectedConvId && ( + + + {/* Name editing (admin only) */} + {isAdminOfSelected ? ( +
+ + setEditNameAr(e.target.value)} + placeholder={t("chat.groupNamePlaceholderAr")} + aria-label={t("chat.groupNameAr")} + dir="rtl" + className="bg-white/70 border-slate-200" + /> + setEditNameEn(e.target.value)} + placeholder={t("chat.groupNamePlaceholderEn")} + aria-label={t("chat.groupNameEn")} + dir="ltr" + className="bg-white/70 border-slate-200" + /> + +
+ ) : ( +
+ {t("chat.settings.viewOnly")} +
+ )} + + {/* Members list */} +
+
+ + {isAdminOfSelected && ( + + )} +
+
+ {selectedConv.participants?.map((p) => { + const display = + (lang === "ar" ? p.displayNameAr : p.displayNameEn) ?? p.username; + const isMe = p.id === user?.id; + return ( +
+ + {p.avatarUrl && ( + + )} + + {display.slice(0, 2).toUpperCase()} + + +
+
+ {display} + {p.isAdmin && ( + + {t("chat.settings.admin")} + + )} + {isMe && ( + + {t("chat.settings.you")} + + )} +
+
+ @{p.username} +
+
+ {isAdminOfSelected && !isMe && ( + + )} +
+ ); + })} +
+
+ + + )} + + {/* Add Members Dialog */} + {showAddMembers && selectedConv?.isGroup && isAdminOfSelected && ( +
setShowAddMembers(false)} + > +
e.stopPropagation()} + > +
+

+ {t("chat.settings.addMembers")} +

+ +
+ +
+ + setAddMembersSearch(e.target.value)} + placeholder={t("chat.searchUsersPlaceholder")} + className={`bg-white/70 border-slate-200 ${isRtl ? "pr-9" : "pl-9"}`} + /> +
+ +
+ {t("chat.participantsCount", { count: addMembersIds.length })} +
+ + +
+ {addableUsers.length === 0 ? ( +
+ {t("chat.noUsersFound")} +
+ ) : ( + addableUsers.map((u) => { + const checked = addMembersIds.includes(u.id); + const display = + (lang === "ar" ? u.displayNameAr : u.displayNameEn) ?? u.username; + return ( + + ); + }) + )} +
+
+ +
+ + +
+
+
+ )} + {selectedConvId ? ( /* Message View */
diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 8f7f0bd4..7e1e6f3b 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -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 { diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index 56c85d92..d9e9e481 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -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 => { + return customFetch( + getAddConversationParticipantsUrl(id), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(addParticipantsBody), + }, + ); +}; + +export const getAddConversationParticipantsMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + 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>, + { id: number; data: BodyType } + > = (props) => { + const { id, data } = props ?? {}; + + return addConversationParticipants(id, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type AddConversationParticipantsMutationResult = NonNullable< + Awaited> +>; +export type AddConversationParticipantsMutationBody = + BodyType; +export type AddConversationParticipantsMutationError = ErrorType; + +/** + * @summary Add participants to a group conversation (admin only) + */ +export const useAddConversationParticipants = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { id: number; data: BodyType }, + 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 => { + return customFetch( + getRemoveConversationParticipantUrl(id, userId), + { + ...options, + method: "DELETE", + }, + ); +}; + +export const getRemoveConversationParticipantMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; userId: number }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + 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>, + { id: number; userId: number } + > = (props) => { + const { id, userId } = props ?? {}; + + return removeConversationParticipant(id, userId, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type RemoveConversationParticipantMutationResult = NonNullable< + Awaited> +>; + +export type RemoveConversationParticipantMutationError = ErrorType; + +/** + * @summary Remove a participant from a group conversation (admin only) + */ +export const useRemoveConversationParticipant = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; userId: number }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { id: number; userId: number }, + TContext +> => { + return useMutation(getRemoveConversationParticipantMutationOptions(options)); +}; + /** * @summary List messages in a conversation */ diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 234a2e85..0e9f0cd8 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -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 diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 6ff1cada..9b0d931f 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -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 */