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
@@ -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<void>
}
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<void>
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<void>
res.status(404).json({ error: "Conversation not found" });
return;
}
await emitConversationUpdate(params.data.id);
res.json(details);
});
async function emitConversationUpdate(conversationId: number): Promise<void> {
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<void> => {
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<void> => {
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<void> => {
const params = ListMessagesParams.safeParse(req.params);
if (!params.success) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+24 -1
View File
@@ -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": "الإشعارات",
+21 -1
View File
@@ -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",
+421 -8
View File
@@ -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<string | null>(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<number[]>([]);
const [addMembersSearch, setAddMembersSearch] = useState("");
const bottomRef = useRef<HTMLDivElement>(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() {
) : (
<MessageCircle size={20} className="text-green-400 shrink-0" />
)}
<h1 className="text-lg font-semibold text-foreground truncate">
{selectedConvId ? convName : t("chat.title")}
</h1>
{selectedConvId && selectedConv?.isGroup && (
<Badge className="bg-primary/15 text-primary border-primary/30 text-[10px] shrink-0">
{t("chat.groupChat")}
</Badge>
{selectedConvId && selectedConv?.isGroup ? (
<button
onClick={openSettings}
className="flex items-center gap-2 min-w-0 text-start hover:opacity-80 transition-opacity"
aria-label={t("chat.settings.openSettings")}
>
<h1 className="text-lg font-semibold text-foreground truncate">{convName}</h1>
<Badge className="bg-primary/15 text-primary border-primary/30 text-[10px] shrink-0">
{t("chat.groupChat")}
</Badge>
</button>
) : (
<h1 className="text-lg font-semibold text-foreground truncate">
{selectedConvId ? convName : t("chat.title")}
</h1>
)}
</div>
{selectedConvId && selectedConv?.isGroup && (
<button
onClick={openSettings}
className="p-2 rounded-xl hover:bg-slate-100 text-muted-foreground hover:text-foreground transition-colors"
aria-label={t("chat.settings.openSettings")}
>
<Settings size={20} />
</button>
)}
{!selectedConvId && (
<button
onClick={openNewConv}
@@ -607,6 +752,274 @@ export default function ChatPage() {
</div>
)}
{/* Group Settings Dialog */}
{showSettings && selectedConv?.isGroup && (
<div
className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onClick={closeSettings}
>
<div
className="glass-panel rounded-3xl p-6 w-full max-w-md space-y-4 max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between">
<h2 className="font-semibold text-foreground text-lg">
{t("chat.settings.title")}
</h2>
<button
onClick={closeSettings}
className="p-1 rounded-lg hover:bg-slate-100 text-muted-foreground"
aria-label={t("common.close")}
>
<X size={18} />
</button>
</div>
{/* Name editing (admin only) */}
{isAdminOfSelected ? (
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground block">
{t("chat.settings.groupName")}
</label>
<Input
value={editNameAr}
onChange={(e) => setEditNameAr(e.target.value)}
placeholder={t("chat.groupNamePlaceholderAr")}
aria-label={t("chat.groupNameAr")}
dir="rtl"
className="bg-white/70 border-slate-200"
/>
<Input
value={editNameEn}
onChange={(e) => setEditNameEn(e.target.value)}
placeholder={t("chat.groupNamePlaceholderEn")}
aria-label={t("chat.groupNameEn")}
dir="ltr"
className="bg-white/70 border-slate-200"
/>
<Button
size="sm"
onClick={handleSaveName}
disabled={!nameDirty || updateConv.isPending}
className="w-full"
>
{updateConv.isPending ? (
<Loader2 size={14} className="animate-spin" />
) : (
t("chat.settings.saveName")
)}
</Button>
</div>
) : (
<div className="text-sm text-muted-foreground">
{t("chat.settings.viewOnly")}
</div>
)}
{/* Members list */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">
{t("chat.settings.members", {
count: selectedConv.participants?.length ?? 0,
})}
</label>
{isAdminOfSelected && (
<button
onClick={() => setShowAddMembers(true)}
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
<UserPlus size={14} />
{t("chat.settings.addMembers")}
</button>
)}
</div>
<div className="space-y-1 max-h-64 overflow-y-auto">
{selectedConv.participants?.map((p) => {
const display =
(lang === "ar" ? p.displayNameAr : p.displayNameEn) ?? p.username;
const isMe = p.id === user?.id;
return (
<div
key={p.id}
className="flex items-center gap-3 p-2 rounded-xl hover:bg-slate-50"
>
<Avatar className="w-8 h-8">
{p.avatarUrl && (
<AvatarImage
src={resolveServiceImageUrl(p.avatarUrl) ?? ""}
alt=""
className="object-cover"
/>
)}
<AvatarFallback className="text-xs bg-primary/20 text-primary">
{display.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground truncate flex items-center gap-2">
{display}
{p.isAdmin && (
<Badge className="bg-primary/15 text-primary border-primary/30 text-[10px]">
{t("chat.settings.admin")}
</Badge>
)}
{isMe && (
<span className="text-xs text-muted-foreground">
{t("chat.settings.you")}
</span>
)}
</div>
<div className="text-xs text-muted-foreground truncate">
@{p.username}
</div>
</div>
{isAdminOfSelected && !isMe && (
<button
onClick={() => handleRemoveMember(p.id)}
disabled={removeParticipant.isPending}
className="p-2 rounded-lg text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
aria-label={t("chat.settings.removeMember")}
>
<Trash2 size={14} />
</button>
)}
</div>
);
})}
</div>
</div>
</div>
</div>
)}
{/* Add Members Dialog */}
{showAddMembers && selectedConv?.isGroup && isAdminOfSelected && (
<div
className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-[60] flex items-center justify-center p-4"
onClick={() => setShowAddMembers(false)}
>
<div
className="glass-panel rounded-3xl p-6 w-full max-w-md space-y-4 max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between">
<h2 className="font-semibold text-foreground text-lg">
{t("chat.settings.addMembers")}
</h2>
<button
onClick={() => setShowAddMembers(false)}
className="p-1 rounded-lg hover:bg-slate-100 text-muted-foreground"
aria-label={t("common.close")}
>
<X size={18} />
</button>
</div>
<div className="relative">
<Search
size={16}
className={`absolute top-1/2 -translate-y-1/2 text-muted-foreground ${
isRtl ? "right-3" : "left-3"
}`}
/>
<Input
value={addMembersSearch}
onChange={(e) => setAddMembersSearch(e.target.value)}
placeholder={t("chat.searchUsersPlaceholder")}
className={`bg-white/70 border-slate-200 ${isRtl ? "pr-9" : "pl-9"}`}
/>
</div>
<div className="text-xs text-muted-foreground">
{t("chat.participantsCount", { count: addMembersIds.length })}
</div>
<ScrollArea className="h-64 -mx-2 px-2">
<div className="space-y-1">
{addableUsers.length === 0 ? (
<div className="text-center text-sm text-muted-foreground py-8">
{t("chat.noUsersFound")}
</div>
) : (
addableUsers.map((u) => {
const checked = addMembersIds.includes(u.id);
const display =
(lang === "ar" ? u.displayNameAr : u.displayNameEn) ?? u.username;
return (
<button
type="button"
key={u.id}
onClick={() => toggleAddMember(u.id)}
className={`w-full flex items-center gap-3 p-2 rounded-xl text-start transition-colors ${
checked
? "bg-primary/10 hover:bg-primary/15"
: "hover:bg-slate-100"
}`}
>
<span
className={`w-5 h-5 shrink-0 rounded-md border-2 flex items-center justify-center transition-colors ${
checked
? "bg-primary border-primary text-primary-foreground"
: "border-slate-300"
}`}
>
{checked && (
<svg
viewBox="0 0 16 16"
className="w-3 h-3"
fill="none"
stroke="currentColor"
strokeWidth="3"
>
<path d="M3 8l3 3 7-7" />
</svg>
)}
</span>
<Avatar className="w-8 h-8">
<AvatarFallback className="text-xs bg-primary/20 text-primary">
{display.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 text-start">
<div className="text-sm font-medium text-foreground truncate">
{display}
</div>
<div className="text-xs text-muted-foreground truncate">
@{u.username}
</div>
</div>
</button>
);
})
)}
</div>
</ScrollArea>
<div className="flex gap-2">
<Button
variant="outline"
className="flex-1"
onClick={() => setShowAddMembers(false)}
>
{t("common.cancel")}
</Button>
<Button
className="flex-1"
onClick={handleAddMembers}
disabled={addMembersIds.length === 0 || addParticipants.isPending}
>
{addParticipants.isPending ? (
<Loader2 size={14} className="animate-spin" />
) : (
t("chat.settings.addSelected")
)}
</Button>
</div>
</div>
</div>
)}
{selectedConvId ? (
/* Message View */
<div className="flex-1 flex flex-col">
@@ -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
*/