Let leaving admins choose who takes over the group (task #46)

Original ask: when the only admin leaves a group, let them pick a
specific successor instead of always auto-promoting the longest-tenured
member. Keep "auto" available, with bilingual labels.

Changes:
- lib/api-spec/openapi.yaml: extend POST /conversations/{id}/leave with
  an optional JSON body { successorId?: number | null }.
- Regenerated lib/api-client-react and lib/api-zod via api-spec codegen.
- artifacts/api-server/src/routes/conversations.ts: parse the new body,
  and when the leaver is the only admin, promote the requested successor
  if one is provided and is a current member; otherwise fall back to the
  existing oldest-member auto-promotion. Returns 400 if successorId is
  not a remaining member.
- artifacts/teaboy-os/src/pages/chat.tsx:
  - Track a successorChoice state ("auto" | userId).
  - In the leave confirmation dialog, when the user is the sole admin
    and there are other members, render a radio-list chooser with an
    "Auto" option (default) plus each remaining member.
  - Pass { successorId } to the leave mutation when a specific member
    is chosen; pass {} for auto.
  - Reset choice on success.
- artifacts/teaboy-os/src/locales/{en,ar}.json: added successorTitle,
  successorHelp, successorAuto strings.

Verification:
- pnpm -w run typecheck passes for libs and all artifacts.
- Attempted an end-to-end browser test; the run was interrupted before
  completion. Backend logic and UI wiring were validated by reading
  through the code paths and type system.

Replit-Task-Id: c9065bc1-ab4e-4a3b-a865-81754f5c2e5a
This commit is contained in:
riyadhafraa
2026-04-21 11:29:39 +00:00
parent a864b84567
commit 0b7593bbc2
9 changed files with 183 additions and 47 deletions
@@ -562,6 +562,14 @@ export interface AdminAppOpensByUser {
opens: AppOpenByUserEntry[];
}
export type LeaveConversationBody = {
/** When the leaver is the only admin, optionally name a specific
remaining member to promote. Omit (or send null) to keep the
automatic behavior of promoting the longest-tenured member.
*/
successorId?: number | null;
};
export type GetAdminStatsParams = {
/**
* Time range for trend stats. Use "custom" with from/to.
+12 -8
View File
@@ -37,6 +37,7 @@ import type {
GetAdminStatsParams,
HealthStatus,
HomeStats,
LeaveConversationBody,
LoginBody,
MessageWithSender,
Notification,
@@ -2988,11 +2989,14 @@ export const getLeaveConversationUrl = (id: number) => {
export const leaveConversation = async (
id: number,
leaveConversationBody?: LeaveConversationBody,
options?: RequestInit,
): Promise<SuccessResponse> => {
return customFetch<SuccessResponse>(getLeaveConversationUrl(id), {
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(leaveConversationBody),
});
};
@@ -3003,14 +3007,14 @@ export const getLeaveConversationMutationOptions = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof leaveConversation>>,
TError,
{ id: number },
{ id: number; data: BodyType<LeaveConversationBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof leaveConversation>>,
TError,
{ id: number },
{ id: number; data: BodyType<LeaveConversationBody> },
TContext
> => {
const mutationKey = ["leaveConversation"];
@@ -3024,11 +3028,11 @@ export const getLeaveConversationMutationOptions = <
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof leaveConversation>>,
{ id: number }
{ id: number; data: BodyType<LeaveConversationBody> }
> = (props) => {
const { id } = props ?? {};
const { id, data } = props ?? {};
return leaveConversation(id, requestOptions);
return leaveConversation(id, data, requestOptions);
};
return { mutationFn, ...mutationOptions };
@@ -3037,7 +3041,7 @@ export const getLeaveConversationMutationOptions = <
export type LeaveConversationMutationResult = NonNullable<
Awaited<ReturnType<typeof leaveConversation>>
>;
export type LeaveConversationMutationBody = BodyType<LeaveConversationBody>;
export type LeaveConversationMutationError = ErrorType<unknown>;
/**
@@ -3050,14 +3054,14 @@ export const useLeaveConversation = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof leaveConversation>>,
TError,
{ id: number },
{ id: number; data: BodyType<LeaveConversationBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof leaveConversation>>,
TError,
{ id: number },
{ id: number; data: BodyType<LeaveConversationBody> },
TContext
> => {
return useMutation(getLeaveConversationMutationOptions(options));
+14
View File
@@ -765,6 +765,20 @@ paths:
required: true
schema:
type: integer
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
successorId:
type: integer
description: |
When the leaver is the only admin, optionally name a specific
remaining member to promote. Omit (or send null) to keep the
automatic behavior of promoting the longest-tenured member.
nullable: true
responses:
"200":
description: Left conversation
+9
View File
@@ -946,6 +946,15 @@ export const LeaveConversationParams = zod.object({
id: zod.coerce.number(),
});
export const LeaveConversationBody = zod.object({
successorId: zod
.number()
.nullish()
.describe(
"When the leaver is the only admin, optionally name a specific\nremaining member to promote. Omit (or send null) to keep the\nautomatic behavior of promoting the longest-tenured member.\n",
),
});
export const LeaveConversationResponse = zod.object({
success: zod.boolean(),
});