Skip to content

Latest commit

 

History

History
286 lines (184 loc) · 14.6 KB

File metadata and controls

286 lines (184 loc) · 14.6 KB

Agent Quirks — Encore Coding (ecdev)

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.


Project Overview

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.


File Map

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

Non-Obvious Architecture

1. Two Supabase Clients — They Are Not Interchangeable

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 sessionauth.getUser() will return null. Always use the server client in route.ts files.


2. Profile Cache — The Single Source of Truth for the Logged-In User

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.


3. The 2FA Flow Is UI-Only Enforcement

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.


4. The 2FA API Routes Read Destination From The DB, Not The Client

/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.phone before sending. On /verify, server reads profiles.phone from DB.
  • Rate limit: 60 seconds per user enforced via profiles.last_otp_sent_at
  • Dev bypass: code 123456 works only when NODE_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.


5. Role and Plan Cannot Be Self-Elevated — Protected by Trigger

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.


6. Content Gating — is_free_preview + plan

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 placesLessonPageClient.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).


7. Community Chat Message Limit for Free Users

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.tsx

After 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.


8. The Admin CMS Has No Server-Side Route Guard

/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.


9. get_my_role() — Security-Sensitive Postgres Function

-- 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).


10. RLS Policies Use (select auth.uid()) Not auth.uid()

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.


11. Lesson Resources Are JSONB

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.


12. Mux Integration

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.


13. The Home Route / Is the Lesson Player

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.


14. Realtime Subscriptions

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.


15. Course Slug Auto-Generation Is Disabled When Editing

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.


16. profiles Table Exposes Phone Numbers to All Authenticated Users

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.


17. Duplicate Indexes Were Removed

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.


18. Onboarding Modal vs Profile Modal

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.


19. Direct Messages Have No Conversation Entity

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.


20. The Middleware Runs on All Routes Including /api/*

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.


Environment Variables

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.


Database Schema Quick Reference

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.

Known Incomplete / TODO

  • 2FA is UI-only — no server-side gate enforcing that OTP was completed before accessing the app
  • Payment integrationplan is set manually; no Stripe or webhook yet
  • Encore AI — the button and modal UI exist in VideoPlayer.tsx but the AI backend is not connected
  • Supporters triggerssupporters table is populated but no notifications fire
  • Phone privacyphone is 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