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) {
|
||||
|
||||
Reference in New Issue
Block a user