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:
@@ -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