Skip to content

Commit 97c8509

Browse files
markgohoclaude
andauthored
Replace GitHub App with Firestore as profile source of truth (#71)
* fix: cache profile data in Firestore to eliminate GitHub sync delay Write profile data to Firestore alongside GitHub on create/update, and read from Firestore first (instant, consistent) instead of GitHub. This removes the retry loop, optimistic signal, sync-pending banner, and timer management from the Angular frontend — a net reduction of ~200 lines. Existing GitHub-only profiles are lazily backfilled on first read. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add unknown type to catch callback variable Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: harden profile caching with fallback, cache invalidation, and tests - Wrap Firestore cache lookup in its own try/catch so GitHub fallback still works when Firestore is unavailable - Clear cached profile data on refund and subscription-ended flows - Add tests for Firestore error fallback, and cache write failures in create/write profile routes - Extract shared buildProfileImageUrl utility - Remove double-logging in saveProfileContent - Use API response data directly instead of reload() in Angular Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address PR review findings and CI failures - Use proper Elysia response types in tests instead of Record<string, unknown> - Add clearProfileCache to ProfileMemberService interface and implementation - Harden error handling in saveProfileContent and setProfileCreatedAt - Use ProfileMemberService.clearProfileCache in stripe webhooks - Upgrade cache lookup logging from warn to error with errorId - Fix factory JSDoc and add unknown type annotation - Remove obsolete E2E retry/sync-pending tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Refactor profiles API and migrate to Firestore - Removed markdown serialization utility as it is no longer needed. - Updated refund and subscription ended processing to trigger Hugo rebuilds. - Removed cached profile data from member documents. - Introduced Firestore-based profiles collection with CRUD operations. - Implemented profile migration script from Hugo markdown to Firestore. - Added synchronization script to keep Firestore profiles in sync with Hugo markdown files. - Updated package dependencies for js-yaml and firebase-admin. * fix: harden Firestore profile operations and clean up stale GitHub references - Use atomic create()/update() instead of check-then-write to prevent race conditions - Add empty-snapshot guard and per-profile error handling in Hugo sync script - Add dedicated API_HUGO_REBUILD_FAILED error ID for trigger-rebuild - Make profile field required in write/create response schemas - Remove dead triggerHugoRebuild re-export from barrel - Update stale GitHub references in comments, test names, and JSDoc - Add TODO for localStorage cleanup removal in profile.service.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix unnecessary optional chains in profile test files The `profile` property on `CreateProfileSuccessResponse` and `WriteProfileSuccessResponse` is non-optional, so optional chaining is flagged by @typescript-eslint/no-unnecessary-condition. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: remove committed profile content, sync from Firestore on build Profiles are now sourced from Firestore at build time instead of being committed as markdown files. The sync script runs before Hugo in CI (both merge and PR preview workflows) and on `bun run hugo:dev` locally. - .gitignore profile directories (hugo/content/doulas/*/) - Add sync step to PR preview workflow for working preview URLs - Replace js-yaml with Bun.YAML.parse and custom block-style serializer - Fix initFirebase to pass explicit projectId for ADC usage - Run sync script before hugo:dev for fresh local data Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use existing service account secret for Firestore sync in CI Both Hugo workflows now write the FIREBASE_SERVICE_ACCOUNT_DOULA_COOPERATIVE secret to a temp file instead of referencing the non-existent FIREBASE_SERVICE_ACCOUNT_JSON_PATH secret. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use printenv to write service account JSON to avoid shell quoting The secret JSON was getting mangled by shell single-quote interpolation. Using printenv writes the exact env var contents without shell parsing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: pass service account JSON as env var instead of temp file Match the pattern from kingston-church: pass FIREBASE_SERVICE_ACCOUNT as an env var, JSON.parse() it in the script, pass to cert(). No temp files needed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add admin draft toggle, draft-aware access control, notification link, and GitHub cleanup - Add POST /admin/members/:memberId/profile/toggle-draft endpoint to publish/unpublish doula profiles from the admin dashboard - Restrict GET /api/profiles/:slug to return 404 for draft profiles unless the requester is an admin or the profile owner (optional auth) - Add direct admin dashboard link to profile creation notification email - Remove redundant GitHub secret validation from profiles handler and 14 orphaned GitHub-related error IDs - Add Publish/Unpublish button with draft status indicator to Angular admin member detail page Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve lint errors in functions and members - Remove useless `undefined` argument (unicorn/no-useless-undefined) - Use direct boolean check instead of `=== false` comparison - Rename `profileRef`/`profileDoc` to full names (unicorn/prevent-abbreviations) - Suppress unused-vars for destructured ownerUid used to strip field Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address PR review findings from comprehensive code review - Add missing updatedAt to toggleProfileDraft (was inconsistent with draftProfile) - Fix falsy guards in createProfile that silently dropped empty arrays/strings - Fix admin notification email referencing Hugo files instead of Firestore documents - Fix script JSDoc referencing wrong env var (GOOGLE_APPLICATION_CREDENTIALS → FIREBASE_SERVICE_ACCOUNT) - Update stale "deleted from GitHub" schema description - Remove unused IMAGEKIT_BASE_URL from sync script - Add explanatory comment to .gitignore for generated Hugo profile directories Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve exactOptionalPropertyTypes error in readProfileBySlugLogic Change userToken parameter from optional property (userToken?) to explicit union type (userToken: DecodedIdToken | undefined) to match what Elysia's derive provides. Fixes CI typecheck failure. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add parameter types to sendEmail mock to fix test typecheck The mock function had no parameter types, causing TypeScript to infer an empty tuple for call arguments, which broke the type assertion on mock.calls[0][0]. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use correct type assertion pattern for sendEmail mock calls Use `as unknown as` pattern (consistent with stripe handler tests) to fix both the TypeScript typecheck error and ESLint no-unused-vars error. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 38fcdd7 commit 97c8509

129 files changed

Lines changed: 2342 additions & 2842 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/hugo-hosting-merge.yml

Lines changed: 6 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ on:
99
paths:
1010
- "hugo/**"
1111
- "firebase.json"
12+
repository_dispatch:
13+
types: [profile-update]
1214
jobs:
1315
build_and_deploy:
1416
runs-on: ubuntu-latest
@@ -21,6 +23,10 @@ jobs:
2123
fetch-depth: 2
2224
- name: Install dependencies
2325
run: bun install
26+
- name: Sync profiles from Firestore
27+
run: bun scripts/sync-profiles-to-hugo.ts
28+
env:
29+
FIREBASE_SERVICE_ACCOUNT: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_DOULA_COOPERATIVE }}
2430
- name: Build Hugo site
2531
run: bun run build
2632
env:
@@ -36,36 +42,3 @@ jobs:
3642
channelId: live
3743
projectId: doula-cooperative
3844
target: main-site
39-
- name: Notify Profile Updates
40-
if: success()
41-
run: |
42-
COMMIT_MSG=$(git log -1 --pretty=%B)
43-
COMMIT_SHA=$(git rev-parse HEAD)
44-
45-
# Get changed profile files (both index.md and profile images)
46-
CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD | \
47-
grep -E "hugo/content/doulas/[^/]+/(.*-profile.*\.(jpg|jpeg|png|webp|avif)|index\.md)$" \
48-
2>/dev/null || echo "")
49-
50-
if [ -z "$CHANGED_FILES" ]; then
51-
FILE_COUNT=0
52-
else
53-
FILE_COUNT=$(echo "$CHANGED_FILES" | wc -l)
54-
fi
55-
56-
# Extract slug if single file (from either index.md or image path)
57-
if [ "$FILE_COUNT" -eq 1 ]; then
58-
# Try extracting from index.md path first
59-
SLUG=$(echo "$CHANGED_FILES" | sed -n 's|hugo/content/doulas/\([^/]*\)/index.md|\1|p')
60-
# If empty, try extracting from image path
61-
if [ -z "$SLUG" ]; then
62-
SLUG=$(echo "$CHANGED_FILES" | sed -n 's|hugo/content/doulas/\([^/]*\)/.*|\1|p')
63-
fi
64-
else
65-
SLUG=""
66-
fi
67-
68-
curl -X POST https://doulacooperative.com/api/profile-deployment-webhook \
69-
-H "Content-Type: application/json" \
70-
-d "{\"commitMessage\":\"$COMMIT_MSG\",\"commitSha\":\"$COMMIT_SHA\",\"slug\":\"$SLUG\",\"secret\":\"${{ secrets.DEPLOY_WEBHOOK_SECRET }}\"}"
71-
continue-on-error: true

.github/workflows/hugo-hosting-pull-request.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ jobs:
2929
- name: Install dependencies
3030
run: bun install
3131

32+
- name: Sync profiles from Firestore
33+
run: bun scripts/sync-profiles-to-hugo.ts
34+
env:
35+
FIREBASE_SERVICE_ACCOUNT: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_DOULA_COOPERATIVE }}
36+
3237
- name: Build Hugo site with test Stripe configuration
3338
run: bun run build
3439
env:

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
3636
# Hugo
3737
public
3838
hugo/resources
39+
# Profile directories are generated at build time from Firestore (sync-profiles-to-hugo.ts)
40+
hugo/content/doulas/*/
3941

4042
# Firebase
4143
*-debug.log

bun.lock

Lines changed: 164 additions & 33 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
getMemberLogic,
1414
listMembersLogic,
1515
refundMembershipLogic,
16+
toggleProfileDraftLogic,
1617
updateClaimsLogic,
1718
updateMemberLogic,
1819
} from "../routes/index.js";
@@ -28,6 +29,7 @@ import {
2829
MemberIdParameterSchema,
2930
RefundMembershipApiResponseSchema,
3031
RefundMembershipBodySchema,
32+
ToggleProfileDraftApiResponseSchema,
3133
UpdateClaimsApiResponseSchema,
3234
UpdateClaimsBodySchema,
3335
UpdateMemberApiResponseSchema,
@@ -145,6 +147,27 @@ export function createAdminMembersPlugin(services?: PartialServices) {
145147
response: CleanSlateApiResponseSchema,
146148
},
147149
)
150+
// POST /:memberId/profile/toggle-draft - Toggle profile draft status (served at /api/admin/members/:memberId/profile/toggle-draft)
151+
.post(
152+
"/profile/toggle-draft",
153+
async ({
154+
params,
155+
adminToken,
156+
memberAdminService,
157+
logger,
158+
set,
159+
}) =>
160+
toggleProfileDraftLogic({
161+
memberId: params.memberId,
162+
adminUid: getAdminUid(adminToken, logger),
163+
memberAdminService,
164+
logger,
165+
set,
166+
}),
167+
{
168+
response: ToggleProfileDraftApiResponseSchema,
169+
},
170+
)
148171
// Membership management routes under /:memberId/membership
149172
.group("/membership", app =>
150173
app

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@ export { extendMembershipLogic } from "./extend-membership.js";
55
export { getMemberLogic } from "./get-member.js";
66
export { listMembersLogic } from "./list-members.js";
77
export { refundMembershipLogic } from "./refund-membership.js";
8+
export { toggleProfileDraftLogic } from "./toggle-profile-draft.js";
89
export { updateClaimsLogic } from "./update-claims.js";
910
export { updateMemberLogic } from "./update-member.js";
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
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 { ToggleProfileDraftResult } from "../services/toggle-profile-draft.js";
8+
import { createAdminTestPlugin } from "../test-utils/create-admin-test-plugin.js";
9+
10+
/**
11+
* Tests for POST /:memberId/profile/toggle-draft.
12+
*
13+
* Uses createAdminTestPlugin() factory with mocked services.
14+
*/
15+
describe("POST /:memberId/profile/toggle-draft", () => {
16+
interface SetupOptions {
17+
memberId?: string;
18+
authToken?: string | null;
19+
memberNotFound?: boolean;
20+
noSlug?: boolean;
21+
profileNotFound?: boolean;
22+
serverError?: boolean;
23+
toggleResult?: ToggleProfileDraftResult;
24+
}
25+
26+
function setup({
27+
memberId = "test-member-id",
28+
authToken = "admin-token",
29+
memberNotFound = false,
30+
noSlug = false,
31+
profileNotFound = false,
32+
serverError = false,
33+
toggleResult,
34+
}: SetupOptions = {}) {
35+
const defaultResult: ToggleProfileDraftResult = {
36+
slug: "test-slug",
37+
draft: false,
38+
hugoRebuildTriggered: true,
39+
};
40+
41+
const mockToggleProfileDraft = mock(
42+
(): Promise<ToggleProfileDraftResult> => {
43+
if (memberNotFound) {
44+
return Promise.reject(
45+
new NotFoundError(`Member with ID ${memberId} not found`),
46+
);
47+
}
48+
if (noSlug) {
49+
return Promise.reject(
50+
new ValidationError(
51+
"Member does not have a profile slug. Cannot toggle draft status.",
52+
),
53+
);
54+
}
55+
if (profileNotFound) {
56+
return Promise.reject(
57+
new NotFoundError("Profile not found for slug: test-slug"),
58+
);
59+
}
60+
if (serverError) {
61+
return Promise.reject(new Error("Firestore unavailable"));
62+
}
63+
return Promise.resolve(toggleResult ?? defaultResult);
64+
},
65+
);
66+
67+
const testApp = createAdminTestPlugin({
68+
memberAdminService: {
69+
toggleProfileDraft: mockToggleProfileDraft,
70+
},
71+
});
72+
73+
const headers: Record<string, string> = {};
74+
if (authToken) {
75+
headers["Authorization"] = `Bearer ${authToken}`;
76+
}
77+
78+
const request = new Request(
79+
`http://localhost/${memberId}/profile/toggle-draft`,
80+
{
81+
method: "POST",
82+
headers,
83+
},
84+
);
85+
86+
return { testApp, request };
87+
}
88+
89+
describe("Authentication", () => {
90+
it("should return 401 when no authorization header is provided", async () => {
91+
const { testApp, request } = setup({ authToken: null });
92+
93+
const response = await handleRequest(testApp, request);
94+
95+
expect(response.status).toBe(401);
96+
const body = (await response.json()) as { error?: string };
97+
expect(body.error).toBe("Missing Authorization header");
98+
});
99+
100+
it("should return 403 when non-admin user tries to toggle draft", async () => {
101+
const { testApp, request } = setup({ authToken: "non-admin-token" });
102+
103+
const response = await handleRequest(testApp, request);
104+
105+
expect(response.status).toBe(403);
106+
const body = (await response.json()) as { error?: string };
107+
expect(body.error).toBe("Admin privileges required");
108+
});
109+
});
110+
111+
describe("Successful toggle", () => {
112+
it("should toggle draft to published and return success", async () => {
113+
const { testApp, request } = setup({
114+
toggleResult: {
115+
slug: "jane-doe",
116+
draft: false,
117+
hugoRebuildTriggered: true,
118+
},
119+
});
120+
121+
const response = await handleRequest(testApp, request);
122+
123+
expect(response.status).toBe(200);
124+
const body = (await response.json()) as {
125+
success?: boolean;
126+
slug?: string;
127+
draft?: boolean;
128+
warning?: string;
129+
};
130+
expect(body.success).toBe(true);
131+
expect(body.slug).toBe("jane-doe");
132+
expect(body.draft).toBe(false);
133+
expect(body.warning).toBeUndefined();
134+
});
135+
136+
it("should toggle draft to unpublished and return success", async () => {
137+
const { testApp, request } = setup({
138+
toggleResult: {
139+
slug: "jane-doe",
140+
draft: true,
141+
hugoRebuildTriggered: true,
142+
},
143+
});
144+
145+
const response = await handleRequest(testApp, request);
146+
147+
expect(response.status).toBe(200);
148+
const body = (await response.json()) as {
149+
success?: boolean;
150+
slug?: string;
151+
draft?: boolean;
152+
};
153+
expect(body.success).toBe(true);
154+
expect(body.slug).toBe("jane-doe");
155+
expect(body.draft).toBe(true);
156+
});
157+
158+
it("should include warning when Hugo rebuild fails", async () => {
159+
const { testApp, request } = setup({
160+
toggleResult: {
161+
slug: "jane-doe",
162+
draft: false,
163+
hugoRebuildTriggered: false,
164+
},
165+
});
166+
167+
const response = await handleRequest(testApp, request);
168+
169+
expect(response.status).toBe(200);
170+
const body = (await response.json()) as {
171+
success?: boolean;
172+
warning?: string;
173+
};
174+
expect(body.success).toBe(true);
175+
expect(body.warning).toContain("Hugo rebuild failed");
176+
});
177+
});
178+
179+
describe("Error handling", () => {
180+
it("should return 404 when member not found", async () => {
181+
const { testApp, request } = setup({ memberNotFound: true });
182+
183+
const response = await handleRequest(testApp, request);
184+
185+
expect(response.status).toBe(404);
186+
const body = (await response.json()) as { error?: string };
187+
expect(body.error).toContain("not found");
188+
});
189+
190+
it("should return 400 when member has no slug", async () => {
191+
const { testApp, request } = setup({ noSlug: true });
192+
193+
const response = await handleRequest(testApp, request);
194+
195+
expect(response.status).toBe(400);
196+
const body = (await response.json()) as { error?: string };
197+
expect(body.error).toContain("profile slug");
198+
});
199+
200+
it("should return 404 when profile not found", async () => {
201+
const { testApp, request } = setup({ profileNotFound: true });
202+
203+
const response = await handleRequest(testApp, request);
204+
205+
expect(response.status).toBe(404);
206+
const body = (await response.json()) as { error?: string };
207+
expect(body.error).toContain("Profile not found");
208+
});
209+
210+
it("should return 500 for unexpected errors", async () => {
211+
const { testApp, request } = setup({ serverError: true });
212+
213+
const response = await handleRequest(testApp, request);
214+
215+
expect(response.status).toBe(500);
216+
const body = (await response.json()) as { error?: string };
217+
expect(body.error).toBeDefined();
218+
expect(body.error).not.toContain("Firestore unavailable");
219+
});
220+
});
221+
});

0 commit comments

Comments
 (0)