Skip to content

Commit 524a91e

Browse files
markgohoclaude
andcommitted
feat(admin-members): add profile approval flow
Add admin profile approval support across the API and members app, including migrated import cleanup when linking profiles so stale import records are removed reliably. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 523cbd2 commit 524a91e

31 files changed

Lines changed: 1308 additions & 19 deletions

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { adminGuard } from "../../shared-api/utils/admin-guard.js";
77
import { getAdminUid } from "../../shared-api/utils/get-admin-uid.js";
88
import {
99
activateMembershipLogic,
10+
approveProfileLogic,
1011
cancelMembershipLogic,
1112
cleanSlateDeleteLogic,
1213
deleteDraftProfileLogic,
@@ -25,6 +26,7 @@ import {
2526
import {
2627
ActivateMembershipApiResponseSchema,
2728
ActivateMembershipBodySchema,
29+
ApproveProfileApiResponseSchema,
2830
CancelMembershipApiResponseSchema,
2931
CleanSlateApiResponseSchema,
3032
DeleteDraftProfileApiResponseSchema,
@@ -189,6 +191,21 @@ export function createAdminMembersPlugin(services?: PartialServices) {
189191
response: ToggleProfileDraftApiResponseSchema,
190192
},
191193
)
194+
// POST /:memberId/profile/approve - Approve member for profile work (served at /api/admin/members/:memberId/profile/approve)
195+
.post(
196+
"/profile/approve",
197+
async ({ params, adminToken, memberAdminService, logger, set }) =>
198+
approveProfileLogic({
199+
memberId: params.memberId,
200+
adminUid: getAdminUid(adminToken, logger),
201+
memberAdminService,
202+
logger,
203+
set,
204+
}),
205+
{
206+
response: ApproveProfileApiResponseSchema,
207+
},
208+
)
192209
// POST /:memberId/profile/delete-draft - Delete draft profile (served at /api/admin/members/:memberId/profile/delete-draft)
193210
.post(
194211
"/profile/delete-draft",
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { describe, expect, it, mock } from "bun:test";
2+
import { Timestamp } from "firebase-admin/firestore";
3+
import {
4+
NotFoundError,
5+
ValidationError,
6+
} from "../../shared-api/errors/http-error.js";
7+
import { handleRequest } from "../../test-utils/handle-request.js";
8+
import type { MemberDocument } from "../../types/member-document.js";
9+
import type { ApproveProfileResult } from "../services/approve-profile.js";
10+
import { createAdminTestPlugin } from "../test-utils/create-admin-test-plugin.js";
11+
12+
describe("POST /:memberId/profile/approve", () => {
13+
interface SetupOptions {
14+
memberId?: string;
15+
authToken?: string | null;
16+
memberNotFound?: boolean;
17+
serverError?: boolean;
18+
isAdminLookupFails?: boolean;
19+
}
20+
21+
function setup({
22+
memberId = "test-member-id",
23+
authToken = "admin-token",
24+
memberNotFound = false,
25+
serverError = false,
26+
isAdminLookupFails = false,
27+
}: SetupOptions = {}) {
28+
const defaultMember: MemberDocument = {
29+
uid: memberId,
30+
email: "member@example.com",
31+
createdAt: Timestamp.now(),
32+
membershipActive: true,
33+
profileApprovedAt: Timestamp.now(),
34+
};
35+
36+
const mockApproveProfile = mock(
37+
(): Promise<ApproveProfileResult> => {
38+
if (memberNotFound) {
39+
return Promise.reject(
40+
new NotFoundError(`Member with ID ${memberId} not found`),
41+
);
42+
}
43+
if (serverError) {
44+
return Promise.reject(new Error("Firestore unavailable"));
45+
}
46+
return Promise.resolve({ member: defaultMember });
47+
},
48+
);
49+
50+
const testApp = createAdminTestPlugin({
51+
memberAdminService: {
52+
approveProfile: mockApproveProfile,
53+
isAdmin: mock((): Promise<boolean> => {
54+
if (isAdminLookupFails) {
55+
return Promise.reject(new ValidationError("Lookup failed"));
56+
}
57+
return Promise.resolve(false);
58+
}),
59+
},
60+
});
61+
62+
const headers: Record<string, string> = {};
63+
if (authToken) {
64+
headers["Authorization"] = `Bearer ${authToken}`;
65+
}
66+
67+
const request = new Request(
68+
`http://localhost/${memberId}/profile/approve`,
69+
{
70+
method: "POST",
71+
headers,
72+
},
73+
);
74+
75+
return { testApp, request };
76+
}
77+
78+
it("should return 401 when no authorization header is provided", async () => {
79+
const { testApp, request } = setup({ authToken: null });
80+
81+
const response = await handleRequest(testApp, request);
82+
83+
expect(response.status).toBe(401);
84+
const body = (await response.json()) as { error?: string };
85+
expect(body.error).toBe("Missing Authorization header");
86+
});
87+
88+
it("should return 403 when non-admin user tries to approve profile work", async () => {
89+
const { testApp, request } = setup({ authToken: "non-admin-token" });
90+
91+
const response = await handleRequest(testApp, request);
92+
93+
expect(response.status).toBe(403);
94+
const body = (await response.json()) as { error?: string };
95+
expect(body.error).toBe("Admin privileges required");
96+
});
97+
98+
it("should return success with updated member data", async () => {
99+
const { testApp, request } = setup();
100+
101+
const response = await handleRequest(testApp, request);
102+
103+
expect(response.status).toBe(200);
104+
const body = (await response.json()) as {
105+
success?: boolean;
106+
member?: {
107+
uid?: string;
108+
email?: string;
109+
profileApprovedAt?: string;
110+
isAdmin?: boolean;
111+
};
112+
};
113+
expect(body.success).toBe(true);
114+
expect(body.member?.uid).toBe("test-member-id");
115+
expect(body.member?.email).toBe("member@example.com");
116+
expect(body.member?.profileApprovedAt).toBeDefined();
117+
expect(body.member?.isAdmin).toBe(false);
118+
});
119+
120+
it("should still return success when isAdmin lookup fails after approval", async () => {
121+
const { testApp, request } = setup({ isAdminLookupFails: true });
122+
123+
const response = await handleRequest(testApp, request);
124+
125+
expect(response.status).toBe(200);
126+
const body = (await response.json()) as {
127+
success?: boolean;
128+
member?: {
129+
profileApprovedAt?: string;
130+
isAdmin?: boolean;
131+
};
132+
};
133+
expect(body.success).toBe(true);
134+
expect(body.member?.profileApprovedAt).toBeDefined();
135+
expect(body.member?.isAdmin).toBe(false);
136+
});
137+
138+
it("should return 404 when member not found", async () => {
139+
const { testApp, request } = setup({ memberNotFound: true });
140+
141+
const response = await handleRequest(testApp, request);
142+
143+
expect(response.status).toBe(404);
144+
const body = (await response.json()) as { error?: string };
145+
expect(body.error).toContain("not found");
146+
});
147+
148+
it("should return 500 for unexpected errors", async () => {
149+
const { testApp, request } = setup({ serverError: true });
150+
151+
const response = await handleRequest(testApp, request);
152+
153+
expect(response.status).toBe(500);
154+
const body = (await response.json()) as { error?: string };
155+
expect(body.error).toBeDefined();
156+
expect(body.error).not.toContain("Firestore unavailable");
157+
});
158+
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { ERROR_IDS } from "../../constants/error-ids.js";
2+
import type { ApproveProfileApiResponse } from "../schemas/member-schemas.js";
3+
import { toMemberResponse } from "../schemas/member-schemas.js";
4+
import type { MemberAdminService } from "../services/interface.js";
5+
import type { Logger } from "../../shared-api/types/logger.js";
6+
import { handleRouteError } from "../../shared-api/utils/route-error-handler.js";
7+
8+
export async function approveProfileLogic({
9+
memberId,
10+
adminUid,
11+
memberAdminService,
12+
logger,
13+
set,
14+
}: {
15+
memberId: string;
16+
adminUid: string;
17+
memberAdminService: MemberAdminService;
18+
logger: Logger;
19+
set: { status?: number | string };
20+
}): Promise<ApproveProfileApiResponse> {
21+
try {
22+
logger.info("Admin approving member for profile work", {
23+
adminUid,
24+
memberId,
25+
});
26+
27+
const result = await memberAdminService.approveProfile({ memberId });
28+
29+
let isAdmin = false;
30+
try {
31+
isAdmin = await memberAdminService.isAdmin(result.member.uid, logger);
32+
} catch {
33+
logger.warn("Failed to check admin status for approved member", {
34+
memberId,
35+
});
36+
}
37+
38+
return {
39+
success: true,
40+
member: toMemberResponse(result.member, isAdmin),
41+
};
42+
} catch (error: unknown) {
43+
return handleRouteError({
44+
error,
45+
operation: "approve member for profile work",
46+
errorId: ERROR_IDS.API_ADMIN_UPDATE_MEMBER_FAILED,
47+
logger,
48+
set,
49+
context: { memberId, adminUid },
50+
}) as ApproveProfileApiResponse;
51+
}
52+
}

functions/src/admin-members-api/routes/delete-draft-profile.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,20 @@ describe("POST /:memberId/profile/delete-draft", () => {
145145
expect(body.warning).toBeUndefined();
146146
});
147147

148+
it("should return success when approval is cleared along with the draft profile", async () => {
149+
const { testApp, request } = setup();
150+
151+
const response = await handleRequest(testApp, request);
152+
153+
expect(response.status).toBe(200);
154+
const body = (await response.json()) as {
155+
success?: boolean;
156+
memberUpdated?: boolean;
157+
};
158+
expect(body.success).toBe(true);
159+
expect(body.memberUpdated).toBe(true);
160+
});
161+
148162
it("should include warning when Hugo rebuild fails", async () => {
149163
const { testApp, request } = setup({
150164
deleteResult: {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { activateMembershipLogic } from "./activate-membership.js";
2+
export { approveProfileLogic } from "./approve-profile.js";
23
export { cancelMembershipLogic } from "./cancel-membership.js";
34
export { cleanSlateDeleteLogic } from "./clean-slate-delete.js";
45
export { deleteDraftProfileLogic } from "./delete-draft-profile.js";

functions/src/admin-members-api/routes/link-profile.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ describe("POST /:memberId/profile/link", () => {
5151
membershipExpiresAt: Timestamp.now(),
5252
slug: "unlinked-doula-profile",
5353
profileCreatedAt: Timestamp.now(),
54+
profileApprovedAt: Timestamp.now(),
5455
};
5556

5657
const mockLinkProfile = mock(
@@ -163,13 +164,15 @@ describe("POST /:memberId/profile/link", () => {
163164
email?: string;
164165
membershipActive?: boolean;
165166
slug?: string;
167+
profileApprovedAt?: string;
166168
isAdmin?: boolean;
167169
};
168170
};
169171
expect(body.success).toBe(true);
170172
expect(body.member?.uid).toBe("test-member-id");
171173
expect(body.member?.email).toBe("member@example.com");
172174
expect(body.member?.slug).toBe("unlinked-doula-profile");
175+
expect(body.member?.profileApprovedAt).toBeDefined();
173176
expect(body.member?.isAdmin).toBe(false);
174177
});
175178

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ export const MemberResponseSchema = t.Object({
8888
description: "Profile creation timestamp (ISO 8601)",
8989
}),
9090
),
91+
profileApprovedAt: t.Optional(
92+
t.String({
93+
format: "date-time",
94+
description: "Profile approval timestamp (ISO 8601)",
95+
}),
96+
),
9197
stripeCustomerId: t.Optional(
9298
t.String({
9399
description: "Stripe customer ID",
@@ -183,6 +189,9 @@ export function toMemberResponse(
183189
...(document.profileCreatedAt !== undefined && {
184190
profileCreatedAt: timestampToIso(document.profileCreatedAt),
185191
}),
192+
...(document.profileApprovedAt !== undefined && {
193+
profileApprovedAt: timestampToIso(document.profileApprovedAt),
194+
}),
186195
...(document.stripeCustomerId !== undefined && {
187196
stripeCustomerId: document.stripeCustomerId,
188197
}),
@@ -385,6 +394,18 @@ export type UpdateMemberApiResponse = Static<
385394
typeof UpdateMemberApiResponseSchema
386395
>;
387396

397+
/**
398+
* POST /api/admin/members/:memberId/profile/approve response - union of success and error.
399+
*/
400+
export const ApproveProfileApiResponseSchema = t.Union([
401+
MemberSuccessResponseSchema,
402+
ErrorResponseSchema,
403+
]);
404+
405+
export type ApproveProfileApiResponse = Static<
406+
typeof ApproveProfileApiResponseSchema
407+
>;
408+
388409
/**
389410
* POST /api/admin/members/:memberId/membership/activate response - union of success and error.
390411
*/

0 commit comments

Comments
 (0)