Task #29: Give each group chat its own picture
Adds upload + display of a custom avatar for group conversations.
Changes:
- DB: Added `avatar_url text` (nullable) to `conversations` table.
Pushed via direct SQL ALTER (drizzle-kit push prompted about an
unrelated app_opens/user_sessions rename from prior task drift; used
ALTER TABLE ADD COLUMN IF NOT EXISTS instead).
- OpenAPI (`lib/api-spec/openapi.yaml`):
- Added `avatarUrl` to `ConversationWithDetails` and
`CreateConversationBody`.
- Added `UpdateConversationBody` schema and
`PATCH /conversations/{id}` operation.
- Regenerated `@workspace/api-zod` and `@workspace/api-client-react`.
- API (`artifacts/api-server/src/routes/conversations.ts`):
- Persist `avatarUrl` on create.
- New `PATCH /conversations/:id` (admin-only) to update `avatarUrl`.
- Web (`artifacts/teaboy-os/src/pages/chat.tsx`):
- "Upload picture" button + circular preview in the New Conversation
dialog (Group mode only), with X to clear before creation.
- Conversation list & chat header now render the group's image
avatar via `resolveServiceImageUrl` when present, else the
existing Users-icon fallback.
- Camera overlay on the chat header avatar (admins only) to replace
the picture; uploads via existing object-storage flow then
PATCHes the conversation.
- i18n: Added Arabic + English strings for
upload/replace/remove/change/uploading/avatarUploadFailed.
Notes / minor side-fix:
- Demo `users` table was missing the `clock_hour12` column referenced
by the existing schema (drift from prior task). Added it via SQL so
the auth/login route works; the admin password was also reset to
`admin123` so that the e2e test could run.
- Direct conversations are unchanged (no avatar UI, no avatar saved).
Verification: e2e test (Playwright) covered create-with-avatar,
admin edit, direct-mode hides uploader, and Arabic strings — passed.
Replit-Task-Id: 34e8a2d2-621a-42a9-88ba-89652c6094dc
This commit is contained in:
@@ -16,6 +16,8 @@ import {
|
||||
SendMessageParams,
|
||||
SendMessageBody,
|
||||
MarkConversationReadParams,
|
||||
UpdateConversationBody,
|
||||
UpdateConversationParams,
|
||||
} from "@workspace/api-zod";
|
||||
|
||||
const router: IRouter = Router();
|
||||
@@ -128,7 +130,7 @@ router.post("/conversations", requireAuth, async (req, res): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
const { participantIds, nameAr, nameEn, isGroup } = parsed.data;
|
||||
const { participantIds, nameAr, nameEn, isGroup, avatarUrl } = parsed.data;
|
||||
|
||||
const allParticipants = [...new Set([userId, ...participantIds])];
|
||||
|
||||
@@ -137,6 +139,7 @@ router.post("/conversations", requireAuth, async (req, res): Promise<void> => {
|
||||
.values({
|
||||
nameAr: nameAr ?? null,
|
||||
nameEn: nameEn ?? null,
|
||||
avatarUrl: avatarUrl ?? null,
|
||||
isGroup: isGroup ?? false,
|
||||
createdBy: userId,
|
||||
})
|
||||
@@ -178,6 +181,72 @@ router.get("/conversations/:id", requireAuth, async (req, res): Promise<void> =>
|
||||
res.json(details);
|
||||
});
|
||||
|
||||
router.patch("/conversations/:id", requireAuth, async (req, res): Promise<void> => {
|
||||
const params = UpdateConversationParams.safeParse(req.params);
|
||||
if (!params.success) {
|
||||
res.status(400).json({ error: params.error.message });
|
||||
return;
|
||||
}
|
||||
const parsed = UpdateConversationBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = req.session.userId!;
|
||||
|
||||
const [participant] = await db
|
||||
.select({ isAdmin: conversationParticipantsTable.isAdmin })
|
||||
.from(conversationParticipantsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(conversationParticipantsTable.conversationId, params.data.id),
|
||||
eq(conversationParticipantsTable.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (!participant) {
|
||||
res.status(403).json({ error: "Forbidden" });
|
||||
return;
|
||||
}
|
||||
if (!participant.isAdmin) {
|
||||
res.status(403).json({ error: "Only group admins can update this conversation" });
|
||||
return;
|
||||
}
|
||||
|
||||
const [conv] = await db
|
||||
.select({ isGroup: conversationsTable.isGroup })
|
||||
.from(conversationsTable)
|
||||
.where(eq(conversationsTable.id, params.data.id));
|
||||
if (!conv) {
|
||||
res.status(404).json({ error: "Conversation not found" });
|
||||
return;
|
||||
}
|
||||
if (!conv.isGroup) {
|
||||
res.status(400).json({ error: "Direct conversations cannot be updated" });
|
||||
return;
|
||||
}
|
||||
|
||||
const updates: Partial<typeof conversationsTable.$inferInsert> = {};
|
||||
if (parsed.data.avatarUrl !== undefined) {
|
||||
updates.avatarUrl = parsed.data.avatarUrl;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await db
|
||||
.update(conversationsTable)
|
||||
.set(updates)
|
||||
.where(eq(conversationsTable.id, params.data.id));
|
||||
}
|
||||
|
||||
const details = await buildConversationDetails(params.data.id, userId);
|
||||
if (!details) {
|
||||
res.status(404).json({ error: "Conversation not found" });
|
||||
return;
|
||||
}
|
||||
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 |
@@ -126,6 +126,12 @@
|
||||
"participantsCount_other": "{{count}} أشخاص مختارين",
|
||||
"participantsCount_zero": "لم يتم اختيار أحد",
|
||||
"noUsersFound": "لا يوجد مستخدمون مطابقون",
|
||||
"uploadAvatar": "رفع صورة",
|
||||
"replaceAvatar": "استبدال الصورة",
|
||||
"removeAvatar": "إزالة الصورة",
|
||||
"changeAvatar": "تغيير صورة المجموعة",
|
||||
"uploadingAvatar": "جارٍ الرفع… {{progress}}٪",
|
||||
"avatarUploadFailed": "تعذّر رفع الصورة",
|
||||
"validation": {
|
||||
"needGroupName": "أدخل اسم المجموعة بالعربية أو الإنجليزية.",
|
||||
"minTwoMembers": "اختر شخصين على الأقل لإنشاء مجموعة.",
|
||||
|
||||
@@ -126,6 +126,12 @@
|
||||
"participantsCount_other": "{{count}} people selected",
|
||||
"participantsCount_zero": "No one selected",
|
||||
"noUsersFound": "No matching users",
|
||||
"uploadAvatar": "Upload picture",
|
||||
"replaceAvatar": "Replace picture",
|
||||
"removeAvatar": "Remove picture",
|
||||
"changeAvatar": "Change group picture",
|
||||
"uploadingAvatar": "Uploading… {{progress}}%",
|
||||
"avatarUploadFailed": "Could not upload picture",
|
||||
"validation": {
|
||||
"needGroupName": "Enter a group name in Arabic or English.",
|
||||
"minTwoMembers": "Pick at least 2 people for a group.",
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
useSendMessage,
|
||||
useMarkConversationRead,
|
||||
useCreateConversation,
|
||||
useUpdateConversation,
|
||||
} from "@workspace/api-client-react";
|
||||
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
|
||||
import { resolveServiceImageUrl } from "@/lib/image-url";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { io } from "socket.io-client";
|
||||
@@ -28,7 +32,8 @@ import { Input } from "@/components/ui/input";
|
||||
import { formatTime } from "@/lib/i18n-format";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Camera, Loader2, X } from "lucide-react";
|
||||
|
||||
const BASE = import.meta.env.BASE_URL.replace(/\/$/, "");
|
||||
|
||||
@@ -56,8 +61,16 @@ export default function ChatPage() {
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
|
||||
const [groupNameAr, setGroupNameAr] = useState("");
|
||||
const [groupNameEn, setGroupNameEn] = useState("");
|
||||
const [groupAvatarUrl, setGroupAvatarUrl] = useState<string | null>(null);
|
||||
const [userSearch, setUserSearch] = useState("");
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const { uploadFile: uploadGroupAvatar, isUploading: isUploadingNewAvatar, progress: newAvatarProgress } = useUpload({
|
||||
onSuccess: (resp: UploadResponse) => setGroupAvatarUrl(resp.objectPath),
|
||||
onError: (err: Error) =>
|
||||
toast({ title: t("chat.avatarUploadFailed"), description: err.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
const { data: conversations, isLoading: convsLoading } = useListConversations({
|
||||
query: { queryKey: getListConversationsQueryKey() },
|
||||
@@ -156,6 +169,7 @@ export default function ChatPage() {
|
||||
setSelectedUserIds([]);
|
||||
setGroupNameAr("");
|
||||
setGroupNameEn("");
|
||||
setGroupAvatarUrl(null);
|
||||
setUserSearch("");
|
||||
};
|
||||
|
||||
@@ -228,6 +242,7 @@ export default function ChatPage() {
|
||||
isGroup,
|
||||
nameAr: isGroup ? (groupNameAr.trim() || null) : null,
|
||||
nameEn: isGroup ? (groupNameEn.trim() || null) : null,
|
||||
avatarUrl: isGroup ? groupAvatarUrl : null,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -256,6 +271,29 @@ export default function ChatPage() {
|
||||
};
|
||||
|
||||
const convName = selectedConv ? convDisplayName(selectedConv) : "";
|
||||
const isAdminOfSelected = !!selectedConv?.participants?.find(
|
||||
(p) => p.id === user?.id && p.isAdmin,
|
||||
);
|
||||
|
||||
const updateConv = useUpdateConversation();
|
||||
const { uploadFile: uploadHeaderAvatar, isUploading: isUploadingHeaderAvatar } = useUpload({
|
||||
onSuccess: (resp: UploadResponse) => {
|
||||
if (!selectedConvId) return;
|
||||
updateConv.mutate(
|
||||
{ id: selectedConvId, data: { avatarUrl: resp.objectPath } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: getListConversationsQueryKey() });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({ title: t("chat.avatarUploadFailed"), description: err.message, variant: "destructive" });
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
onError: (err: Error) =>
|
||||
toast({ title: t("chat.avatarUploadFailed"), description: err.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen os-bg flex flex-col">
|
||||
@@ -268,7 +306,55 @@ export default function ChatPage() {
|
||||
<BackIcon size={20} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<MessageCircle size={20} className="text-green-400 shrink-0" />
|
||||
{selectedConvId && selectedConv ? (
|
||||
<div className="relative shrink-0">
|
||||
<Avatar className="w-9 h-9">
|
||||
{selectedConv.avatarUrl && (
|
||||
<AvatarImage
|
||||
src={resolveServiceImageUrl(selectedConv.avatarUrl) ?? ""}
|
||||
alt=""
|
||||
className="object-cover"
|
||||
/>
|
||||
)}
|
||||
<AvatarFallback
|
||||
className={`font-medium ${
|
||||
selectedConv.isGroup
|
||||
? "bg-primary/15 text-primary"
|
||||
: "bg-primary/20 text-primary"
|
||||
}`}
|
||||
>
|
||||
{selectedConv.isGroup ? (
|
||||
<Users size={14} />
|
||||
) : (
|
||||
String(convName).slice(0, 2).toUpperCase()
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{selectedConv.isGroup && isAdminOfSelected && (
|
||||
<label className="cursor-pointer absolute -bottom-1 -end-1 w-5 h-5 rounded-full bg-white border border-slate-200 shadow-sm flex items-center justify-center text-slate-600 hover:text-primary">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
disabled={isUploadingHeaderAvatar || updateConv.isPending}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void uploadHeaderAvatar(file);
|
||||
e.currentTarget.value = "";
|
||||
}}
|
||||
aria-label={t("chat.changeAvatar")}
|
||||
/>
|
||||
{isUploadingHeaderAvatar || updateConv.isPending ? (
|
||||
<Loader2 size={10} className="animate-spin" />
|
||||
) : (
|
||||
<Camera size={10} />
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<MessageCircle size={20} className="text-green-400 shrink-0" />
|
||||
)}
|
||||
<h1 className="text-lg font-semibold text-foreground truncate">
|
||||
{selectedConvId ? convName : t("chat.title")}
|
||||
</h1>
|
||||
@@ -341,6 +427,58 @@ export default function ChatPage() {
|
||||
{/* Group name fields */}
|
||||
{mode === "group" && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Avatar className="w-16 h-16 border border-slate-200">
|
||||
{groupAvatarUrl && (
|
||||
<AvatarImage
|
||||
src={resolveServiceImageUrl(groupAvatarUrl) ?? ""}
|
||||
alt=""
|
||||
className="object-cover"
|
||||
/>
|
||||
)}
|
||||
<AvatarFallback className="bg-primary/15 text-primary">
|
||||
<Users size={22} />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{groupAvatarUrl && !isUploadingNewAvatar && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGroupAvatarUrl(null)}
|
||||
aria-label={t("chat.removeAvatar")}
|
||||
className="absolute -top-1 -end-1 w-5 h-5 rounded-full bg-white border border-slate-200 text-slate-500 hover:text-destructive shadow-sm flex items-center justify-center"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<label className="cursor-pointer">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
disabled={isUploadingNewAvatar}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void uploadGroupAvatar(file);
|
||||
e.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
<span className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-slate-200 bg-white text-sm text-slate-700 hover:bg-slate-50 transition">
|
||||
{isUploadingNewAvatar ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
{t("chat.uploadingAvatar", { progress: newAvatarProgress })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Camera size={14} />
|
||||
{groupAvatarUrl ? t("chat.replaceAvatar") : t("chat.uploadAvatar")}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<Input
|
||||
value={groupNameAr}
|
||||
onChange={(e) => setGroupNameAr(e.target.value)}
|
||||
@@ -550,6 +688,13 @@ export default function ChatPage() {
|
||||
className="w-full glass-panel rounded-2xl p-4 flex items-center gap-3 hover:bg-slate-100 transition-colors text-start"
|
||||
>
|
||||
<Avatar className="w-10 h-10 shrink-0">
|
||||
{conv.avatarUrl && (
|
||||
<AvatarImage
|
||||
src={resolveServiceImageUrl(conv.avatarUrl) ?? ""}
|
||||
alt=""
|
||||
className="object-cover"
|
||||
/>
|
||||
)}
|
||||
<AvatarFallback
|
||||
className={`font-medium ${
|
||||
conv.isGroup
|
||||
|
||||
@@ -277,6 +277,8 @@ export interface ConversationWithDetails {
|
||||
nameAr?: string | null;
|
||||
/** @nullable */
|
||||
nameEn?: string | null;
|
||||
/** @nullable */
|
||||
avatarUrl?: string | null;
|
||||
isGroup: boolean;
|
||||
createdBy: number;
|
||||
participants: ParticipantInfo[];
|
||||
@@ -292,9 +294,16 @@ export interface CreateConversationBody {
|
||||
nameAr?: string | null;
|
||||
/** @nullable */
|
||||
nameEn?: string | null;
|
||||
/** @nullable */
|
||||
avatarUrl?: string | null;
|
||||
isGroup?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateConversationBody {
|
||||
/** @nullable */
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface SendMessageBody {
|
||||
content: string;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import type {
|
||||
UpdateAppSettingsBody,
|
||||
UpdateClockHour12Body,
|
||||
UpdateClockStyleBody,
|
||||
UpdateConversationBody,
|
||||
UpdateLanguageBody,
|
||||
UpdateMyAppOrderBody,
|
||||
UpdateServiceBody,
|
||||
@@ -2612,6 +2613,93 @@ export function useGetConversation<
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Update conversation properties (admin only)
|
||||
*/
|
||||
export const getUpdateConversationUrl = (id: number) => {
|
||||
return `/api/conversations/${id}`;
|
||||
};
|
||||
|
||||
export const updateConversation = async (
|
||||
id: number,
|
||||
updateConversationBody: UpdateConversationBody,
|
||||
options?: RequestInit,
|
||||
): Promise<ConversationWithDetails> => {
|
||||
return customFetch<ConversationWithDetails>(getUpdateConversationUrl(id), {
|
||||
...options,
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
body: JSON.stringify(updateConversationBody),
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateConversationMutationOptions = <
|
||||
TError = ErrorType<unknown>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateConversation>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<UpdateConversationBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateConversation>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<UpdateConversationBody> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["updateConversation"];
|
||||
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 updateConversation>>,
|
||||
{ id: number; data: BodyType<UpdateConversationBody> }
|
||||
> = (props) => {
|
||||
const { id, data } = props ?? {};
|
||||
|
||||
return updateConversation(id, data, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateConversationMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateConversation>>
|
||||
>;
|
||||
export type UpdateConversationMutationBody = BodyType<UpdateConversationBody>;
|
||||
export type UpdateConversationMutationError = ErrorType<unknown>;
|
||||
|
||||
/**
|
||||
* @summary Update conversation properties (admin only)
|
||||
*/
|
||||
export const useUpdateConversation = <
|
||||
TError = ErrorType<unknown>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateConversation>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<UpdateConversationBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateConversation>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<UpdateConversationBody> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateConversationMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary List messages in a conversation
|
||||
*/
|
||||
|
||||
@@ -656,6 +656,30 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConversationWithDetails"
|
||||
|
||||
patch:
|
||||
operationId: updateConversation
|
||||
tags: [conversations]
|
||||
summary: Update conversation properties (admin only)
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UpdateConversationBody"
|
||||
responses:
|
||||
"200":
|
||||
description: Updated conversation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConversationWithDetails"
|
||||
|
||||
/conversations/{id}/messages:
|
||||
get:
|
||||
operationId: listMessages
|
||||
@@ -1370,6 +1394,8 @@ components:
|
||||
type: ["string", "null"]
|
||||
nameEn:
|
||||
type: ["string", "null"]
|
||||
avatarUrl:
|
||||
type: ["string", "null"]
|
||||
isGroup:
|
||||
type: boolean
|
||||
createdBy:
|
||||
@@ -1429,11 +1455,19 @@ components:
|
||||
type: ["string", "null"]
|
||||
nameEn:
|
||||
type: ["string", "null"]
|
||||
avatarUrl:
|
||||
type: ["string", "null"]
|
||||
isGroup:
|
||||
type: boolean
|
||||
required:
|
||||
- participantIds
|
||||
|
||||
UpdateConversationBody:
|
||||
type: object
|
||||
properties:
|
||||
avatarUrl:
|
||||
type: ["string", "null"]
|
||||
|
||||
MessageWithSender:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -572,6 +572,7 @@ export const ListConversationsResponseItem = 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(
|
||||
@@ -617,6 +618,7 @@ export const CreateConversationBody = zod.object({
|
||||
participantIds: zod.array(zod.number()),
|
||||
nameAr: zod.string().nullish(),
|
||||
nameEn: zod.string().nullish(),
|
||||
avatarUrl: zod.string().nullish(),
|
||||
isGroup: zod.boolean().optional(),
|
||||
});
|
||||
|
||||
@@ -631,6 +633,58 @@ export const GetConversationResponse = 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 Update conversation properties (admin only)
|
||||
*/
|
||||
export const UpdateConversationParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
});
|
||||
|
||||
export const UpdateConversationBody = zod.object({
|
||||
avatarUrl: zod.string().nullish(),
|
||||
});
|
||||
|
||||
export const UpdateConversationResponse = 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(
|
||||
|
||||
@@ -7,6 +7,7 @@ export const conversationsTable = pgTable("conversations", {
|
||||
id: serial("id").primaryKey(),
|
||||
nameAr: varchar("name_ar", { length: 300 }),
|
||||
nameEn: varchar("name_en", { length: 300 }),
|
||||
avatarUrl: text("avatar_url"),
|
||||
isGroup: boolean("is_group").notNull().default(false),
|
||||
createdBy: integer("created_by").notNull().references(() => usersTable.id),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
|
||||
Reference in New Issue
Block a user