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:
riyadhafraa
2026-04-21 07:46:16 +00:00
parent bfdfffd8b5
commit 55b39f7d6f
10 changed files with 415 additions and 3 deletions
@@ -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) {