A guide for AI agents (or humans) picking up this codebase. Documents non-obvious architecture decisions, gotchas, and things that look simple but aren't.
A private course platform built with Next.js 15 App Router + Supabase (Postgres, Auth, Realtime, Storage). Members pay for premium access, watch video lessons (Mux), chat in a Slack-style community, and DM each other. A superadmin can manage all content through a built-in CMS at /admin.
Stack: Next.js 15, Supabase (SSR client), Tailwind CSS, Mux (video), Twilio Verify (2FA), react-icons, lucide-react.
src/
app/
page.tsx → / (lesson page — renders LessonPageClient)
courses/page.tsx → /courses (course catalog)
community/page.tsx → /community
messages/page.tsx → /messages (DMs)
supporters/page.tsx → /supporters
admin/page.tsx → /admin (CMS — just renders <AdminCMS />)
login/page.tsx → /login (renders <AuthPage />)
api/
2fa/send/route.ts → POST: sends OTP via Twilio Verify
2fa/verify/route.ts → POST: verifies OTP via Twilio Verify
og-preview/route.ts → GET: scrapes Open Graph metadata for link previews
components/
AuthPage.tsx → Login/signup + multi-step 2FA state machine
Sidebar.tsx → Left nav; real-time unread DM badge
MainLayout.tsx → Wrapper for all authenticated pages
LessonPageClient.tsx → The actual lesson viewer (video + lesson list)
VideoPlayer.tsx → Mux iframe + Encore AI modal + resources
LessonList.tsx → Sidebar list of lessons; handles locked state
LockedModal.tsx → Modal shown when a free user clicks a locked lesson
CoursesPage.tsx → Course catalog grid
CommunityPage.tsx → Real-time community chat (multi-channel)
MessagesPage.tsx → Direct messages
AdminCMS.tsx → CMS for courses/modules/lessons (superadmin only)
SupportersPage.tsx → Supporters management
OnboardingModal.tsx → 4-step new-user onboarding
ProfileModal.tsx → Edit profile, avatar upload, sign out
lib/
supabase/client.ts → Browser Supabase client
supabase/server.ts → Server Supabase client (reads cookies)
profileCache.ts → localStorage cache for the logged-in user's profile
middleware.ts → Session refresh + auth redirect for all routes
src/lib/supabase/client.ts — uses createBrowserClient. Use this in "use client" components.
src/lib/supabase/server.ts — uses createServerClient with cookies() from next/headers. Use this in Server Components, Server Actions, and API route handlers (route.ts).
Using the browser client in an API route handler will appear to work but will not have a valid session — auth.getUser() will return null. Always use the server client in route.ts files.
src/lib/profileCache.ts implements stale-while-revalidate in localStorage.
- Key:
ea_profile_v1 - Soft TTL: 5 min — serve cached data, fire background refresh
- Hard TTL: 30 min — cache is expired, must await fresh fetch
Four functions to know:
| Function | When to call |
|---|---|
getCachedProfile(userId) |
On mount, before any Supabase profile fetch |
setCachedProfile(userId, profile) |
After a fresh DB fetch |
patchCachedProfile(userId, patch) |
After any partial profile save (name, avatar, etc.) |
clearProfileCache() |
On logout — critical, do this before signing out |
If you add a new column to profiles that a component needs, you must add it to the CachedProfile interface in profileCache.ts and update all the select(...) strings that fetch profile data. There are approximately 6–7 components that fetch profiles; they all check the cache first.
The cache only stores the currently logged-in user's own profile. It is not a general-purpose profile store for other users.
After supabase.auth.signInWithPassword() succeeds, the user already has a full, valid Supabase session. The middleware will let them through to any route. The 2FA step in AuthPage.tsx is enforced only by the client-side state machine — it is not a hard server-side gate.
The state machine steps: form → channel-select → phone-setup → otp → onboarding
The "remember me" checkbox writes ea_2fa_{userId} to localStorage with a 30-day expiry. On next login, if isTrustedDevice(userId) returns true, the OTP step is skipped entirely.
When 2FA is eventually hardened server-side, the right approach is middleware that checks a custom claim or a two_fa_verified_at session metadata field.
/api/2fa/send and /api/2fa/verify both authenticate via supabase.auth.getUser() server-side and look up the phone/email from the database. The client does not supply to.
- For email: server reads
profiles.email - For SMS: client sends the phone number (needed for first-time setup), server validates E.164 format and saves it to
profiles.phonebefore sending. On/verify, server readsprofiles.phonefrom DB. - Rate limit: 60 seconds per user enforced via
profiles.last_otp_sent_at - Dev bypass: code
123456works only whenNODE_ENV !== "production"
AuthPage.tsx still keeps pendingEmail and pendingTo state — these are used only for display (masked destination shown in the UI), not sent to the API.
There's a Postgres BEFORE UPDATE trigger on profiles:
trg_protect_profile_sensitive_fields → protect_profile_sensitive_fields()
It silently resets role and plan back to their DB values if the caller's role is not superadmin. Even if a user crafts a direct UPDATE profiles SET role='superadmin' call via the anon key, the trigger resets it. This protects against the RLS gap where "update own row" didn't restrict which columns could change.
Two things determine if a user can watch a lesson:
function canWatch(lesson: Lesson): boolean {
if (lesson.is_free_preview) return true; // always watchable
if (userRole === "superadmin" || userRole === "staff") return true;
return userPlan === "premium"; // paid members only
}This logic exists in two places — LessonPageClient.tsx and LessonList.tsx. Keep them in sync if you change the logic.
plan is "free" or "premium" on profiles. There is no payment integration yet — it's set manually (via AdminCMS or direct DB edit).
Free users can send 10 community messages total (not per session — across all time). The count is fetched from the community_messages table on page load.
const FREE_MSG_LIMIT = 10; // in CommunityPage.tsxAfter the 10th message is sent, an upgrade modal appears. Any attempt to send after that reopens the modal without sending.
This limit applies only to community channels — not to direct messages.
/admin/page.tsx simply renders <AdminCMS />. There is no server-side role check on the page itself. The AdminCMS component does a useEffect check of the user's role and redirects to / if not superadmin.
The actual data is protected by RLS — the Staff can manage courses/modules/lessons policies require get_my_role() to return staff or superadmin for any mutation. So a non-admin reaching the CMS UI would see the form but all writes would fail silently from Supabase.
-- SECURITY DEFINER function used in almost every RLS policy
SELECT role::text FROM public.profiles WHERE id = auth.uid();It is SECURITY DEFINER so it bypasses RLS when reading profiles. All Staff can manage ... policies call get_my_role(). If you need to add a new privilege check in RLS, use get_my_role() or (select get_my_role()) (the latter avoids per-row re-evaluation).
All RLS policies were updated to use (select auth.uid()) (an InitPlan subquery). This makes Postgres evaluate the function once per query instead of once per row. If you add new RLS policies, always use the (select auth.uid()) form.
Lessons have a resources JSONB NOT NULL DEFAULT '[]' column. Each entry is { label: string, url: string }. The AdminCMS LessonsTab has a dynamic resource editor. The VideoPlayer displays them — GitHub URLs get FaSquareGithub icon, others get ExternalLink.
Videos live on Mux. Only the mux_playback_id is stored in the lessons table. The player in VideoPlayer.tsx is a plain <iframe> pointed at https://stream.mux.com/{muxPlaybackId}. There is no Mux SDK dependency.
app/page.tsx renders <LessonPageClient />. The lesson player determines the current course from the URL slug (or defaults to the first published course). The course catalog is at /courses.
Sidebar.tsx subscribes to direct_messages inserts to show a live unread DM badge. CommunityPage.tsx subscribes to community_messages inserts for live chat. Both use supabase.channel(...) and clean up on unmount. If you move these components or unmount them in an unexpected way, ensure the cleanup callbacks (supabase.removeChannel(...)) still fire.
In AdminCMS.tsx / CoursesTab, the slug field auto-fills from the title during create mode. When in edit mode, auto-generation is disabled and the slug field shows a warning: "Changing the slug will break existing links." Courses are accessed by slug, so changing it breaks any bookmarked or shared URLs.
RLS for profiles SELECT allows any authenticated user to read all columns (including phone, two_fa_enabled, two_fa_channel). In practice, app queries always specify explicit column lists and never fetch other users' phone numbers. But a direct API call could read them. Planned fix: move sensitive fields to a separate user_private table with strict RLS. Not done yet — accepted risk for a closed community.
course_enrollments and lesson_progress previously had duplicate unique constraints. The redundant ones (course_enrollments_user_course_unique, lesson_progress_user_lesson_unique) were dropped. The _key constraint variants are the ones that remain.
OnboardingModal.tsx — shown to new users after signup/2FA. Collects name, age, interests, country. Sets onboarding_completed = true on finish.
ProfileModal.tsx — shown when clicking the user avatar in the sidebar. Edits the same fields post-onboarding. Also handles avatar upload to the avatars Supabase Storage bucket and logout.
Both call patchCachedProfile() after saving.
There is no conversations table. DM threads are identified by the (sender_id, recipient_id) pair on the direct_messages table. MessagesPage.tsx groups messages by conversation partner. Unread count in the sidebar is computed from direct_messages WHERE recipient_id = me AND read = false.
middleware.ts matches everything except static files and images. This means unauthenticated requests to /api/2fa/send or /api/2fa/verify will get an HTTP 307 redirect to /login (returning HTML, not JSON). The API routes themselves also call supabase.auth.getUser() and return 401 JSON for good measure, but the middleware redirect happens first in a browser context.
NEXT_PUBLIC_SUPABASE_URL — Supabase project URL
NEXT_PUBLIC_SUPABASE_ANON_KEY — Supabase anon key (safe to expose)
TWILIO_ACCOUNT_SID — Twilio account (server-side only)
TWILIO_AUTH_TOKEN — Twilio auth token (server-side only)
TWILIO_VERIFY_SERVICE_SID — Twilio Verify service SID (server-side only)
Twilio vars are never prefixed with NEXT_PUBLIC_ and are only read inside src/app/api/2fa/ routes.
| Table | Key Points |
|---|---|
profiles |
One row per auth user. Created by trigger auto_create_profile_on_signup. Has role (enum: superadmin/staff/member), plan (free/premium), phone, last_otp_sent_at. Role/plan protected from self-elevation by trigger. |
courses |
Has slug (unique). Used in URLs. Changing slug breaks links. |
modules |
Groups lessons within a course ("sections" in the UI). |
lessons |
Has is_free_preview, mux_playback_id, resources (JSONB). |
lesson_progress |
Tracks per-user lesson completion. Upserted by the player. |
course_enrollments |
Links users to courses. |
community_channels |
Pre-seeded with at least share-your-work. |
community_messages |
Has user_id FK to profiles. Last 30 days shown in UI. |
direct_messages |
No conversation entity. Partitioned by sender/recipient pair. Has read boolean. |
supporters |
User-managed list. Notification triggers not yet implemented. |
lesson_ratings |
Per-user rating (1 = helpful, 2 = not helpful). Unique on (user_id, lesson_id). Delete row = back to neutral. |
- 2FA is UI-only — no server-side gate enforcing that OTP was completed before accessing the app
- Payment integration —
planis set manually; no Stripe or webhook yet - Encore AI — the button and modal UI exist in
VideoPlayer.tsxbut the AI backend is not connected - Supporters triggers —
supporterstable is populated but no notifications fire - Phone privacy —
phoneis readable by all authenticated users via the profiles SELECT policy - Rate limiting in production — current 60s rate limit uses a DB column (
last_otp_sent_at), which is fine for single instances; no Redis needed