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 eebd020f..5fc27c2a 100644 Binary files a/artifacts/teaboy-os/public/opengraph.jpg and b/artifacts/teaboy-os/public/opengraph.jpg differ 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 */