DEV Community

AJ
AJ

Posted on

Building Ekehi — Week 4: Admin Tools, Profile Images, Submissions, and Closing the Loop

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 NULL to the profiles table via a Supabase migration
  • Installed multer for multipart/form-data parsing with memory storage (5MB limit, JPEG/PNG/WebP only)
  • Created a shared upload.middleware.js with the multer config, reusable across routes
  • Updated POST /auth/signup to accept an optional profileImage file alongside existing form fields
  • Added GET /profile — returns the authenticated user's full profile
  • Added PATCH /profile — updates firstName, lastName, and/or profileImage
  • Built storage.utils.js with shared helpers: uploadProfileImage, getPublicImageUrl, deleteImage
  • Images stored in the ekehi-assets bucket at profile-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 Dropdown component, not native <select>, keeping the UI consistent
  • Submitted opportunities land as approval_status: "pending" — straight into the admin review queue
  • POST /opportunities is 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 parsePagination as a shared utility — removes repeated Math.max/min clamping across controllers
  • Extracted extractBearerToken in auth.middleware.js — used by both requireAuth and optionalAuth
  • DRY'd the dropdown mount calls on the submissions form
  • Fixed a misleading comment on the rate limiter: said 15 min window, actual value was 2 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)