Skip to content

Commit 9d9f18a

Browse files
markgohoclaude
andcommitted
feat: add admin draft profile deletion flow
Let admins delete a member's draft profile without removing the member account so they can relink an existing profile. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ffe276e commit 9d9f18a

14 files changed

Lines changed: 625 additions & 10 deletions

File tree

functions/src/admin-members-api/plugins/admin-members-plugin.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
activateMembershipLogic,
1010
cancelMembershipLogic,
1111
cleanSlateDeleteLogic,
12+
deleteDraftProfileLogic,
1213
extendMembershipLogic,
1314
getMemberLogic,
1415
linkProfileLogic,
@@ -25,6 +26,7 @@ import {
2526
ActivateMembershipBodySchema,
2627
CancelMembershipApiResponseSchema,
2728
CleanSlateApiResponseSchema,
29+
DeleteDraftProfileApiResponseSchema,
2830
ExtendMembershipApiResponseSchema,
2931
ExtendMembershipBodySchema,
3032
GetMemberApiResponseSchema,
@@ -183,6 +185,29 @@ export function createAdminMembersPlugin(services?: PartialServices) {
183185
response: ToggleProfileDraftApiResponseSchema,
184186
},
185187
)
188+
// POST /:memberId/profile/delete-draft - Delete draft profile (served at /api/admin/members/:memberId/profile/delete-draft)
189+
.post(
190+
"/profile/delete-draft",
191+
async ({
192+
params,
193+
adminToken,
194+
memberAdminService,
195+
emailService,
196+
logger,
197+
set,
198+
}) =>
199+
deleteDraftProfileLogic({
200+
memberId: params.memberId,
201+
adminUid: getAdminUid(adminToken, logger),
202+
memberAdminService,
203+
...(emailService !== undefined && { emailService }),
204+
logger,
205+
set,
206+
}),
207+
{
208+
response: DeleteDraftProfileApiResponseSchema,
209+
},
210+
)
186211
// GET /:memberId/profile - Read member profile (served at /api/admin/members/:memberId/profile)
187212
.get(
188213
"/profile",
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import { describe, expect, it, mock } from "bun:test";
2+
import {
3+
NotFoundError,
4+
ValidationError,
5+
} from "../../shared-api/errors/http-error.js";
6+
import { handleRequest } from "../../test-utils/handle-request.js";
7+
import type { DeleteDraftProfileResult } from "../services/delete-draft-profile.js";
8+
import { createAdminTestPlugin } from "../test-utils/create-admin-test-plugin.js";
9+
10+
describe("POST /:memberId/profile/delete-draft", () => {
11+
interface SetupOptions {
12+
memberId?: string;
13+
authToken?: string | null;
14+
memberNotFound?: boolean;
15+
noSlug?: boolean;
16+
profileNotDraft?: boolean;
17+
profileNotFound?: boolean;
18+
serverError?: boolean;
19+
deleteResult?: DeleteDraftProfileResult;
20+
}
21+
22+
function setup({
23+
memberId = "test-member-id",
24+
authToken = "admin-token",
25+
memberNotFound = false,
26+
noSlug = false,
27+
profileNotDraft = false,
28+
profileNotFound = false,
29+
serverError = false,
30+
deleteResult,
31+
}: SetupOptions = {}) {
32+
const defaultResult: DeleteDraftProfileResult = {
33+
slug: "test-slug",
34+
profileDeleted: true,
35+
profileImageDeleted: true,
36+
memberUpdated: true,
37+
hugoRebuildTriggered: true,
38+
};
39+
40+
const mockDeleteDraftProfile = mock(
41+
(): Promise<DeleteDraftProfileResult> => {
42+
if (memberNotFound) {
43+
return Promise.reject(
44+
new NotFoundError(`Member with ID ${memberId} not found`),
45+
);
46+
}
47+
if (noSlug) {
48+
return Promise.reject(
49+
new ValidationError(
50+
"Member does not have a profile slug. Cannot delete draft profile.",
51+
),
52+
);
53+
}
54+
if (profileNotDraft) {
55+
return Promise.reject(
56+
new ValidationError(
57+
'Profile for slug "test-slug" is published and cannot be deleted with this action.',
58+
),
59+
);
60+
}
61+
if (profileNotFound) {
62+
return Promise.reject(
63+
new NotFoundError("Profile not found for slug: test-slug"),
64+
);
65+
}
66+
if (serverError) {
67+
return Promise.reject(new Error("Firestore unavailable"));
68+
}
69+
return Promise.resolve(deleteResult ?? defaultResult);
70+
},
71+
);
72+
73+
const testApp = createAdminTestPlugin({
74+
memberAdminService: {
75+
deleteDraftProfile: mockDeleteDraftProfile,
76+
},
77+
});
78+
79+
const headers: Record<string, string> = {};
80+
if (authToken) {
81+
headers["Authorization"] = `Bearer ${authToken}`;
82+
}
83+
84+
const request = new Request(
85+
`http://localhost/${memberId}/profile/delete-draft`,
86+
{
87+
method: "POST",
88+
headers,
89+
},
90+
);
91+
92+
return { testApp, request };
93+
}
94+
95+
describe("Authentication", () => {
96+
it("should return 401 when no authorization header is provided", async () => {
97+
const { testApp, request } = setup({ authToken: null });
98+
99+
const response = await handleRequest(testApp, request);
100+
101+
expect(response.status).toBe(401);
102+
const body = (await response.json()) as { error?: string };
103+
expect(body.error).toBe("Missing Authorization header");
104+
});
105+
106+
it("should return 403 when non-admin user tries to delete draft profile", async () => {
107+
const { testApp, request } = setup({ authToken: "non-admin-token" });
108+
109+
const response = await handleRequest(testApp, request);
110+
111+
expect(response.status).toBe(403);
112+
const body = (await response.json()) as { error?: string };
113+
expect(body.error).toBe("Admin privileges required");
114+
});
115+
});
116+
117+
describe("Successful delete", () => {
118+
it("should return deletion statuses and slug on success", async () => {
119+
const { testApp, request } = setup({
120+
deleteResult: {
121+
slug: "jane-doe",
122+
profileDeleted: true,
123+
profileImageDeleted: true,
124+
memberUpdated: true,
125+
hugoRebuildTriggered: true,
126+
},
127+
});
128+
129+
const response = await handleRequest(testApp, request);
130+
131+
expect(response.status).toBe(200);
132+
const body = (await response.json()) as {
133+
success?: boolean;
134+
slug?: string;
135+
profileDeleted?: boolean;
136+
profileImageDeleted?: boolean;
137+
memberUpdated?: boolean;
138+
warning?: string;
139+
};
140+
expect(body.success).toBe(true);
141+
expect(body.slug).toBe("jane-doe");
142+
expect(body.profileDeleted).toBe(true);
143+
expect(body.profileImageDeleted).toBe(true);
144+
expect(body.memberUpdated).toBe(true);
145+
expect(body.warning).toBeUndefined();
146+
});
147+
148+
it("should include warning when Hugo rebuild fails", async () => {
149+
const { testApp, request } = setup({
150+
deleteResult: {
151+
slug: "jane-doe",
152+
profileDeleted: true,
153+
profileImageDeleted: true,
154+
memberUpdated: true,
155+
hugoRebuildTriggered: false,
156+
},
157+
});
158+
159+
const response = await handleRequest(testApp, request);
160+
161+
expect(response.status).toBe(200);
162+
const body = (await response.json()) as {
163+
success?: boolean;
164+
warning?: string;
165+
};
166+
expect(body.success).toBe(true);
167+
expect(body.warning).toContain("Hugo rebuild failed");
168+
});
169+
});
170+
171+
describe("Error handling", () => {
172+
it("should return 404 when member not found", async () => {
173+
const { testApp, request } = setup({ memberNotFound: true });
174+
175+
const response = await handleRequest(testApp, request);
176+
177+
expect(response.status).toBe(404);
178+
const body = (await response.json()) as { error?: string };
179+
expect(body.error).toContain("not found");
180+
});
181+
182+
it("should return 400 when member has no slug", async () => {
183+
const { testApp, request } = setup({ noSlug: true });
184+
185+
const response = await handleRequest(testApp, request);
186+
187+
expect(response.status).toBe(400);
188+
const body = (await response.json()) as { error?: string };
189+
expect(body.error).toContain("profile slug");
190+
});
191+
192+
it("should return 400 when profile is not draft", async () => {
193+
const { testApp, request } = setup({ profileNotDraft: true });
194+
195+
const response = await handleRequest(testApp, request);
196+
197+
expect(response.status).toBe(400);
198+
const body = (await response.json()) as { error?: string };
199+
expect(body.error).toContain("published");
200+
});
201+
202+
it("should return 404 when profile not found", async () => {
203+
const { testApp, request } = setup({ profileNotFound: true });
204+
205+
const response = await handleRequest(testApp, request);
206+
207+
expect(response.status).toBe(404);
208+
const body = (await response.json()) as { error?: string };
209+
expect(body.error).toContain("Profile not found");
210+
});
211+
212+
it("should return 500 for unexpected errors", async () => {
213+
const { testApp, request } = setup({ serverError: true });
214+
215+
const response = await handleRequest(testApp, request);
216+
217+
expect(response.status).toBe(500);
218+
const body = (await response.json()) as { error?: string };
219+
expect(body.error).toBeDefined();
220+
expect(body.error).not.toContain("Firestore unavailable");
221+
});
222+
});
223+
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { ERROR_IDS } from "../../constants/error-ids.js";
2+
import type { EmailServiceInterface } from "../../shared-api/services/email/index.js";
3+
import type { Logger } from "../../shared-api/types/logger.js";
4+
import { handleRouteError } from "../../shared-api/utils/route-error-handler.js";
5+
import type { DeleteDraftProfileApiResponse } from "../schemas/member-schemas.js";
6+
import type { MemberAdminService } from "../services/interface.js";
7+
8+
/**
9+
* Route handler for POST /:memberId/profile/delete-draft.
10+
* Deletes a member's draft profile while preserving the member account.
11+
*/
12+
export async function deleteDraftProfileLogic({
13+
memberId,
14+
adminUid,
15+
memberAdminService,
16+
emailService,
17+
logger,
18+
set,
19+
}: {
20+
memberId: string;
21+
adminUid: string;
22+
memberAdminService: MemberAdminService;
23+
emailService?: EmailServiceInterface;
24+
logger: Logger;
25+
set: { status?: number | string };
26+
}): Promise<DeleteDraftProfileApiResponse> {
27+
try {
28+
const result = await memberAdminService.deleteDraftProfile({
29+
memberId,
30+
...(emailService !== undefined && { emailService }),
31+
});
32+
33+
logger.info("Admin deleted draft profile", {
34+
adminUid,
35+
memberId,
36+
slug: result.slug,
37+
profileDeleted: result.profileDeleted,
38+
profileImageDeleted: result.profileImageDeleted,
39+
memberUpdated: result.memberUpdated,
40+
hugoRebuildTriggered: result.hugoRebuildTriggered,
41+
});
42+
43+
const warnings: string[] = [];
44+
if (!result.hugoRebuildTriggered) {
45+
warnings.push("Hugo rebuild failed");
46+
}
47+
if (!result.profileImageDeleted) {
48+
warnings.push("Profile image deletion failed or not found");
49+
}
50+
51+
return {
52+
success: true,
53+
slug: result.slug,
54+
profileDeleted: result.profileDeleted,
55+
profileImageDeleted: result.profileImageDeleted,
56+
memberUpdated: result.memberUpdated,
57+
...(warnings.length > 0 && { warning: warnings.join("; ") }),
58+
};
59+
} catch (error) {
60+
return handleRouteError({
61+
error,
62+
operation: "delete draft profile",
63+
errorId: ERROR_IDS.API_ADMIN_DELETE_DRAFT_PROFILE_FAILED,
64+
logger,
65+
set,
66+
context: { memberId, adminUid },
67+
});
68+
}
69+
}

functions/src/admin-members-api/routes/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export { activateMembershipLogic } from "./activate-membership.js";
22
export { cancelMembershipLogic } from "./cancel-membership.js";
33
export { cleanSlateDeleteLogic } from "./clean-slate-delete.js";
4+
export { deleteDraftProfileLogic } from "./delete-draft-profile.js";
45
export { extendMembershipLogic } from "./extend-membership.js";
56
export { getMemberLogic } from "./get-member.js";
67
export { linkProfileLogic } from "./link-profile.js";

functions/src/admin-members-api/schemas/member-schemas.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,46 @@ export type ToggleProfileDraftApiResponse = Static<
598598
typeof ToggleProfileDraftApiResponseSchema
599599
>;
600600

601+
/**
602+
* Success response for deleting a draft profile.
603+
*/
604+
export const DeleteDraftProfileResponseSchema = t.Object({
605+
success: t.Literal(true),
606+
slug: t.String({
607+
description: "The profile slug that was deleted",
608+
}),
609+
profileDeleted: t.Boolean({
610+
description: "Whether the profile document was deleted",
611+
}),
612+
profileImageDeleted: t.Boolean({
613+
description: "Whether the profile image was deleted from ImageKit",
614+
}),
615+
memberUpdated: t.Boolean({
616+
description: "Whether the member document profile fields were cleared",
617+
}),
618+
warning: t.Optional(
619+
t.String({
620+
description: "Warning if non-critical steps failed",
621+
}),
622+
),
623+
});
624+
625+
export type DeleteDraftProfileResponse = Static<
626+
typeof DeleteDraftProfileResponseSchema
627+
>;
628+
629+
/**
630+
* POST /api/admin/members/:memberId/profile/delete-draft response - union of success and error.
631+
*/
632+
export const DeleteDraftProfileApiResponseSchema = t.Union([
633+
DeleteDraftProfileResponseSchema,
634+
ErrorResponseSchema,
635+
]);
636+
637+
export type DeleteDraftProfileApiResponse = Static<
638+
typeof DeleteDraftProfileApiResponseSchema
639+
>;
640+
601641
/**
602642
* Profile content schema for admin read profile response.
603643
* Includes all ProfileData fields plus metadata.

0 commit comments

Comments
 (0)