This is part of an ongoing series documenting the build of Ekehi, a platform connecting African entrepreneurs with funding opportunities, training programmes, and business resources. I'm AJ, the Engineering Lead on the project.
Where We Left Off
Sprint 3 gave us a polished opportunities section — redesigned detail page, working save feature, correct saved state on page load, and a contributors page with skeleton loading. Sprint 4 had a different character. Less redesign, more completion. The goal was to close out everything still outstanding: ship profile image support end-to-end, build the submissions page, wire the resources section to the real API, and deliver a functioning admin content management system with approval, rejection, and delete.
Seven issues created on Monday morning. All closed by Thursday. Issue #80 — the long-running tracker for wiring all frontend pages to the real API — finally closed.
What I Personally Shipped
1. Profile Image Upload — Backend (PR #114)
The first task of the week was building profile image upload into the auth and profile flow.
What was built:
- Added
profile_image_path TEXT NULLto theprofilestable via a Supabase migration - Installed
multerformultipart/form-dataparsing with memory storage (5MB limit, JPEG/PNG/WebP only) - Created a shared
upload.middleware.jswith the multer config, reusable across routes - Updated
POST /auth/signupto accept an optionalprofileImagefile alongside existing form fields - Added
GET /profile— returns the authenticated user's full profile - Added
PATCH /profile— updatesfirstName,lastName, and/orprofileImage - Built
storage.utils.jswith shared helpers:uploadProfileImage,getPublicImageUrl,deleteImage - Images stored in the
ekehi-assetsbucket atprofile-images/<userId>/avatar.<ext>with upsert
One deliberate design decision: the database stores the storage path, not the full URL. The full URL is derived at read-time via getPublicUrl(). This decouples the data from the infrastructure — if the bucket or region ever changes, nothing in the database needs updating.
2. Profile Image Upload — Frontend (PR #130)
With the backend in place, the nav avatar now fetches the user's profile on mount, reads profile_image_path, derives the public URL, and renders it. If the image is absent or fails to load, the placeholder icon shows — the user never sees a broken image element.
3. Admin Content Management — Full Lifecycle (PR #136)
This was the most significant backend work of the sprint. Content submitted by users needed to be reviewable, approvable, rejectable, and deletable by admins — all from a single admin interface.
Approval and rejection flow
Built PATCH /admin/{contentType}/{id}/review accepting { decision: "approved" } or { decision: "rejected", feedback: "..." }. On approval, approval_status is updated to "approved" and the item immediately appears on public endpoints. On rejection, feedback is required — enforced on both client and server.
Every review writes an audit record to the content_reviews table: who reviewed it, when, the decision, and any feedback. This gives the admin team a full history of every content decision.
Delete functionality
Built DELETE /admin/{contentType}/{id} protected by adminGuard. Works on any approval status. The review page shows a Danger Zone card with a delete button. Clicking it opens a confirmation dialog before the request fires — permanent deletion cannot be triggered by a stray click.
Queue status filters
Previously the admin queue only showed pending content. Approved and rejected content was unreachable from the admin UI. Added a two-row tab system: status tabs (Pending / Approved / Rejected) above type tabs (All / Funding / Training / Guide / Template). The full content lifecycle is now manageable from one place.
4. RBAC Implementation (Issue #120)
Completed the role-based access control system. Added a CHECK constraint to the profiles table enforcing only valid roles: super-admin, data-manager, content-editor, user. The adminGuard middleware — which protects all /admin/* routes — validates both authentication and role before any admin operation executes.
All destructive operations (approve, reject, delete) sit behind this guard. The separation is clean: public routes, authenticated user routes, and admin routes are three distinct layers with no overlap.
5. Submissions Page, Resources Wiring, and Clean Code (PR #136)
Submissions page (/submissions/)
Built a protected 3-section accordion form for authenticated users to submit funding opportunities. The sections are: About the Opportunity, Programme Details, and About the Organiser — so the form doesn't feel like an overwhelming wall of fields.
Key decisions:
- Auth-gated on the client: unauthenticated visits redirect to login with
?redirect=/submissions/ - Dropdowns use the existing
Dropdowncomponent, not native<select>, keeping the UI consistent - Submitted opportunities land as
approval_status: "pending"— straight into the admin review queue -
POST /opportunitiesis open to any authenticated user, not just admins, so the community can contribute
Guides and templates wired to real API
Both sections were rendering hardcoded placeholder data. This PR replaced all of that with real API calls, loading skeletons, and proper error states.
Clean code pass
- Extracted
parsePaginationas a shared utility — removes repeatedMath.max/minclamping across controllers - Extracted
extractBearerTokeninauth.middleware.js— used by bothrequireAuthandoptionalAuth - DRY'd the dropdown mount calls on the submissions form
- Fixed a misleading comment on the rate limiter: said
15 min window, actual value was2 min
What I Coordinated as Engineering Lead
Seven issues created on Monday, all closed by end of week:
| Issue | Feature | Assignee |
|---|---|---|
| #113 | Backend: profile image upload | Me |
| #115 | Frontend: build training detail page | Marion |
| #116 | Frontend: add Guides section to resources page | Victor |
| #117 | Frontend: add Templates section to resources page | Florence |
| #118 | Frontend: build guide detail page | Oluchi |
| #119 | Frontend: build template detail page | Esther |
| #120 | Complete RBAC implementation | Me |
The team also shipped two bug fixes beyond their assigned issues — Iyobosa fixed filter wrapping on the training page and added empty-state messaging, and Gabriel shipped loading skeletons for opportunity and training card lists.
Technical Decisions and Why
Store storage path, not full URL
The first implementation stored the full Supabase Storage URL in the database. The problem: that URL is tied to the bucket name, project ID, and region. If any of those change, every stored URL breaks. Storing just the path (profile-images/<userId>/avatar.jpeg) and deriving the URL at read-time via getPublicUrl() means the database is infrastructure-agnostic. getPublicUrl() is synchronous — no extra network call, no performance cost.
Fixed filename avatar.<ext> per user
Generating a unique filename per upload (e.g., timestamped) accumulates stale files in storage every time a user updates their photo. A fixed path with upsert means one file per user, always replaced. No cleanup job needed.
Multer memory storage, not disk
The server runs on Render — no persistent disk. Writing temp files would fail between requests anyway. Memory storage keeps the pipeline direct: file arrives → buffer → forward to Supabase → discard.
Rejection requires feedback, approval does not
Approving content is a binary yes. Rejecting content without explanation leaves the submitter with no way to improve their submission. Feedback is required on rejection, enforced at both the API and UI layer.
Submissions open to all authenticated users
Restricting submissions to admins would make the content team a permanent bottleneck. The approval_status: "pending" flow handles gatekeeping — every submission goes through review before going live. The submission endpoint does not need to be the gatekeeper.
Two-row tab system for admin queue
The first design had only type filters. But pending content was the only reachable state — approved and rejected items had no UI entry point. Status tabs were added as the primary row because status reflects the workflow (pending → decision), with type as secondary filtering. Approved content now has a home in the admin UI.
Bugs I Encountered and Fixed
Admin dashboard showing empty — data?.items vs data
The admin dashboard stats were showing zero counts and the queue was showing no pending items, even though the database had content.
Root cause: the API returns { success, data: [...] }. The frontend was reading data?.items — which is always undefined because the array is at data, not data.items. One character fix, but it made the entire admin interface appear broken.
Guide TOC scrolling to wrong section
Clicking a guide's table of contents entry scrolled to the wrong section. The TOC was built from one data source, the body rendered from another — they had drifted out of sync.
Fix: Derive the TOC by mapping over the same content array used to render the body. Single source of truth — the two can never diverge.
Multer field name mismatch
Profile image uploads were silently failing. The frontend was sending the file as profileImage (camelCase) but the multer config expected profile_image (snake_case). The field name mismatch meant multer never found the file.
Fix: Aligned field names across frontend and middleware configuration.
Guide cards rendering with no description text
Guide cards were showing a title but no text beneath. The frontend was reading card.summary, the API was returning card.description. A field name mismatch that only surfaces when placeholder data is replaced with real API responses.
Sprint Reflections
What worked well
The team is moving faster than ever. Issues that took two or three days in Sprint 2 are turning around in under 24 hours. The shared patterns — skeleton loading, the Dropdown component, the response envelope, the adminGuard — mean contributors spend less time on decisions and more time building.
Closing issue #80 this sprint felt like a milestone. It had been open since Sprint 2 and tracked a wide surface area of unfinished API wiring. The app is now fully connected end to end.
What I would do differently
PR #136 bundled too much — submissions form, resources wiring, admin work, and a clean code pass all in one. It made review harder and history less readable. Going forward I want each PR to have a single concern, even if that means opening more of them.
I also want to define API contracts — agreed field names, response shapes — before assigning frontend issues. The card.summary vs card.description mismatch and the data?.items bug both happened because frontend and backend were built independently without a shared spec.
What I'm Most Proud Of
The admin content management system. It is not flashy but it is the piece that makes the platform viable. Without it, submitted content just sits in the database with no way for the team to act on it. With it, the full lifecycle works: a user submits an opportunity → it lands in the review queue → an admin approves or rejects it with feedback → approved content goes live → if needed, it can be deleted. Every step is covered, audited, and protected by role-based access control.
That is the infrastructure that makes everything else on the platform trustworthy.
Team
Another strong sprint from the team:
- Marion (@MarionBraide) — Training detail page, card rendering bug fix
- Victor (@Okoukoni-Victor) — Guides section on resources page
- Florence (@Florence-code-hub) — Templates section on resources page
- Oluchi (@luchiiii) — Guide detail page
- Esther (@first-afk) — Template detail page, hero section title fix
- Iyobosa (@Fhave) — Training page filter fix, empty state messaging
- Gabriel (@GabrielAbubakar) — Loading skeletons for opportunity and training card lists
GitHub repository: github.com/Tabi-Project/Ekehi
Top comments (0)