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
@@ -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;
}
+88
View File
@@ -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
*/
+34
View File
@@ -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:
+54
View File
@@ -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(
+1
View File
@@ -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(),