Skip to content
All work

2025 — 2026· frontend

Sanctuary Altar of Christ Ministry

A church's public site, admin dashboard and donation flow — eight months live, and an audit that found it could take money without recording it.

Scope

Sole developer, and the one who decided it should exist — this was proposed rather than commissioned. Public site, admin dashboard, donation flow and the Firebase backend behind them. Started from a Vite/React template; the 115 commits after the first are mine. Live and maintained across eight months.

Screens

  • The homepage. Give sits in the header rather than inside a menu — the shortest path from arriving to giving, which is the path the whole donation architecture exists to protect.
  • The public site, language switcher open. Nine languages including Igbo, Hausa and Yoruba — in Abuja, a congregation that does not all read English is not an edge case.
  • The admin dashboard. Every figure here traces back to a record a signature-verified webhook wrote rather than to something a browser claimed, which is what makes the donation trend a report instead of an estimate.
  • Members. Counters, search, filters, then rows with their actions inline — the same shape the other collections use, which is the only reason a volunteer learns it once and can then edit anything. The records shown are test data.
  • Announcements by email or as push to PWA subscribers, filtered by membership status and type. The recipient count sits next to the filters rather than behind a confirm dialog — a volunteer should see who they are about to message before they write it.
  • Settings, edited by volunteers rather than by me. The constraint that shaped this entire surface was that anything needing a runbook would simply never get used.
  • The gallery manager. Upload, search and per-item controls in the same shape as every other collection — and built for the empty case as deliberately as the full one, because empty is what a volunteer meets on their first day.

Problem

One place for a church to run itself: sermons and events published as they happen, members reachable, and giving handled online in Naira. The people editing it daily are volunteers, not developers, and the congregation reaches it on mobile data of varying quality. The hard part was never the public site — it was that a privileged admin surface and an anonymous public one had to live in the same deployment, with no server between them.

Constraints

  • Paystack, in Naira. The payment provider was a given rather than a choice, and its flow shaped the whole donation path.
  • Content edited daily by volunteers who will never read documentation. Anything requiring a runbook would simply not get used.
  • Firebase as the entire backend — no server to run, and therefore no trusted place to execute logic unless it was written as a callable function or a security rule.
  • A congregation on mobile connections that drop. Offline could not mean a browser error page.
  • One deployment serving both an anonymous public site and a privileged admin dashboard.

Decisions

Authorization in security rules, not in route guards

Client-side guards decide what is *shown*; Firestore rules decide what is *allowed*. Every privileged collection is gated by rules that read the caller's role server-side, so hiding a menu item is a courtesy rather than the protection. Bypassing the UI gets you nothing.

Roles as documents rather than custom claims

Roles live in a `user_roles` collection that rules can read directly, and client writes to it are denied outright — only the admin SDK can assign one. Custom claims would have avoided a read per check but require a token refresh to take effect, which means a demoted admin keeps their access until they sign out again.

A hand-written service worker instead of a PWA plugin

Network-first for navigation and Firebase so content is never stale; cache-first for static assets; video fetched but never cached, because filling a phone's storage with sermon recordings is a way to get uninstalled. Offline falls back to the cached shell rather than the browser's error page.

The transaction reference is the document ID

Donations are written with `setDoc` keyed on the Paystack reference rather than `addDoc` with a generated one. Paystack retries webhooks until it gets a 200, so duplicate delivery is expected rather than exceptional — and keying on the reference makes a repeat write land on the same document instead of creating a second record. Idempotency becomes a property of the data model rather than a check somebody has to remember to write.

The client proposes, the server confirms

The record is written as `pending` before Paystack opens, so an abandoned payment leaves something to reconcile instead of nothing. Promotion to `success` comes from a Cloud Function that verifies the `x-paystack-signature` HMAC against the raw request body — parsing first and re-serialising would silently never match — and rejects before doing any other work. Security rules enforce the split rather than trusting it: a client may only ever write `pending`, and `success` is reachable only through the admin SDK, which bypasses rules entirely. A forged donation record is not discouraged, it is unreachable.

Trade-offs

Gave up

A hand-written service worker rather than an off-the-shelf PWA plugin

Bought

Explicit control over what is cached and what is deliberately not — video excluded outright. The cost is that every caching bug is now mine, and the service worker is the area the commit history shows me fixing most often after the UI.

Gave up

A Firestore read on every permission check

Bought

Role changes that take effect immediately, instead of whenever the user's token next refreshes

Gave up

Any server of my own

Bought

Nothing to operate, patch or pay for — and, as it turned out, nowhere trusted to confirm that a payment actually happened. That trade is the origin of the defect below, and I did not see it at the time.

Results

Live

8 months

and still maintained

Commits

116

Entities managed

12

sermons, events, members, donations, prayers, contacts and more

Ratios are given as raw counts rather than percentages. A percentage implies a precision that twenty attempts do not have.

What I got wrong

  • SHIPPED BROKEN, SINCE FIXED — the first version recorded donations only from a client-side callback. If the browser closed after Paystack took the money but before that callback ran, the charge succeeded and no record existed; a failed write after a successful charge showed the donor an error while the money was already gone. I found it auditing my own payment path months after shipping, not because anyone reported it — which is the uncomfortable part. A silent failure produces no complaints.
  • THE REWRITE moved the trust boundary. A signature-verified webhook is now the authority on whether a payment happened and the client merely proposes, while the transaction reference became the Firestore document ID so retried deliveries converge instead of multiplying. The lesson generalises past payments: anything a client asserts about money is a claim rather than a fact, and the architecture should make that distinction structural instead of relying on everyone remembering it.
  • FIXED — one callable function verified admin rights and its neighbour checked only that the caller was signed in. Two functions written weeks apart, one hardened and one not, which is how authorization gaps survive review. A shared assertion helper existed precisely to prevent this and I did not apply it consistently.
  • ALSO FIXED — the client could originally write `status: 'success'` directly as a UI convenience, so a forged record was possible even though no money moved. Rules now accept only `pending` from a client. The general shape of all three of these: the first version trusted the browser to report what had happened, and every fix was some version of moving that authority to somewhere the browser cannot reach.

Stack

  • React
  • TypeScript
  • Vite
  • Firebase
  • Cloud Functions
  • Firestore Rules
  • React Query
  • React Hook Form
  • Chart.js
  • Paystack