ข้ามไปยังเนื้อหา

Architecture

Deep technical reference for the codebase. If you only need a high-level pitch see the root README.md instead.

Audience: the next dev (Vet 87 / 88 / 89 · or any open-source contributor) who needs to maintain or extend this system after Palm graduates.

Last verified against the live Supabase project on the date in the most recent git commit. Schema details are checked with mcp__supabase-cuvetsmo__list_tables and the SQL files under supabase/migrations/.


WebCUVETSMO is a single-page React app hosted on Vercel that talks to Supabase (Postgres + auth + storage + edge functions) for everything stateful. There is no custom Node/Express backend. Background jobs (Instagram harvest, status notifications, Google Calendar push, etc.) live as Supabase Edge Functions and are triggered either by Postgres webhooks or by pg_cron rows in the cron.job table.

flowchart LR
User["Student / Board / Faculty<br/>(browser · PWA-installable)"]
Vercel["Vercel<br/>cuvetsmo.com · CDN + SPA"]
Supabase["Supabase<br/>Postgres 17 · Auth · Storage"]
Edge["Edge Functions (Deno)<br/>notify-status-change · ai-chat<br/>ig-harvest · clubs-logo-refresh<br/>google-oauth-callback · google-calendar-push"]
Cron["pg_cron<br/>(cron.job · 3 entries)"]
Google["Google OAuth + Calendar API"]
IG["Instagram CDN<br/>(public profile pages)"]
LINE["LINE Messaging API"]
OA["OpenAlex API<br/>(paper search · AI chat tool)"]
Sentry["Sentry<br/>(frontend errors)"]
User --> Vercel
Vercel -->|REST + Auth| Supabase
Vercel -->|SSE| Edge
Vercel --> Sentry
Supabase -->|UPDATE webhook on projects| Edge
Cron -->|HTTP POST every 6h / hourly| Edge
Edge -->|push LINE message| LINE
Edge -->|fetch HTML + media| IG
Edge -->|OAuth code exchange| Google
Edge -->|tool call| OA
Edge -->|store result| Supabase

The frontend SPA is the system of record from the user’s point of view; Supabase is the system of record from the operator’s point of view. Everything in between is an Edge Function.


LayerChoiceNotes
Frontend frameworkReact 19Lazy-loaded routes (see src/App.tsx)
BuildVite 6ES2018 target keeps older mobile devices supported
RoutingReact Router 6All routes declared in src/App.tsx
StylingTailwind CSS 3Brand palette in tailwind.config.js (smo-* and stone-*)
Auth + DB + storageSupabase JS v2Single SDK for everything
PWAvite-plugin-pwa + WorkboxNetworkFirst cache for REST, precache for shell
i18ni18next + react-i18nextLocales in src/locales/ (th.json, en.json)
DOCX exportdocx npm packageLazy-imported on click
Error monitoring@sentry/reactVITE_SENTRY_DSN optional
E2E test@playwright/testnpm run test:smoke
Edge Function runtimeDeno (Supabase)All in supabase/functions/

See package.json for exact versions.


Every table in public has RLS enabled (verified live via mcp__supabase-cuvetsmo__list_tables). The table below is the canonical source of truth for the schema. Sources: SQL files under supabase/migrations/ plus live introspection.

TablePurposeKey FKs / notesRLS
profilesOne row per auth.users · holds nickname, role, cohort, divisionid → auth.users.id · division → divisions.slugyes
divisions20 ฝ่าย (organizational divisions) · seededchair_id → profiles.idyes
clubs22 ชมรม (student clubs) · multi-link + cover image + Drive folderpresident_id → profiles.id · links JSONB arrayyes
projectsThe killer feature · CUVET 1.0 proposal schema with 16-state workflowcreated_by → profiles.id · status enumyes
approval_stepsImmutable history · one row per status transitionproject_id → projects.id · actor_id → profiles.idyes
project_commentsReview feedback thread per project · sectionedproject_id · author_id · section (added 0048)yes
project_documentsPer-project receipts / photos / signed letters · 10 MB capproject_id · uploader_id · kind enum · file_url → storage.bucket(project-documents)yes
events12-month event calendar editable by boardorganizer_division → divisions.slug (optional)yes
event_photosPublic-facing photo gallery per eventevent_id → events.id (nullable for legacy) · uploader_id · 0022 added club_id linkyes
event_attendanceCheck-in records (0020) · used to drive Activity Transcriptevent_id · user_id · checked_in_atyes
instagram_postsHand-curated featured IG embeds (predecessor to ig_feed_cache)manual rowsyes
ig_user_idsMap of Instagram handle → IG numeric user_id · driver of harvesterunique on handleyes
ig_feed_cacheAuto-harvested feed (caption, thumbnail, like/comment counts) for embedded IG strip on /clubs etc.ig_user_id → ig_user_idsyes
faculty_staticHardcoded faculty leadership rows (พี่ปุ๋ย, พี่หริ, etc.) not in profilesadmin-only writeyes
push_subscriptionsWeb Push endpoints per deviceuser_id → profiles.idyes
shop_productsMarketplace listings (Phase shop)club_id → clubs.idyes
shop_product_variantsSize/color variants of productsproduct_id → shop_products.idyes
shop_ordersCustomer ordersbuyer_id → profiles.idyes
shop_order_itemsLine items per orderorder_id · variant_idyes
shop_payment_proofsSlip uploads · private bucketorder_id · uploader_idyes
shop_payment_ledgerAppend-only payment events (deposit, refund)order_idyes
app_settingsSingle-row config (feature flags, banner text) · key/valueid = 1 enforcedyes
meeting_pollsReal-time meeting polls (Yes/No, multi-choice)created_byyes
meeting_poll_responsesAnswers · realtime publication for live tallypoll_id · responder_idyes
announcementsBanner / homepage announcementspublished_byyes
ai_chat_conversationsPersisted AI chat threads · per useruser_id → profiles.idyes
ai_chat_messagesMessages in each threadconversation_id · role enumyes
audit_logAppend-only record of sensitive writes (role changes, schema edits via admin UI)actor_id · action · target_table · target_idyes
integration_tokensOAuth refresh tokens for Google Calendar push (per admin user)user_id · provider · refresh_token (encrypted col)yes
alumni_profilesVet alumni directory (Phase 6 starter)NEW · sparse useyes

Discrepancy note: the repo’s supabase/functions/ directory contains 5 functions but the live project has 6 (clubs-logo-refresh is deployed but its source is not committed). See §6.


Auth is Supabase JWT-based (email/password, magic link, or Google OAuth). The flow is implemented in src/lib/auth.tsx:

sequenceDiagram
participant U as User
participant App as React SPA
participant SB as Supabase Auth
participant DB as Postgres (trigger)
U->>App: enter email + password / Google OAuth
App->>SB: signInWithPassword / signInWithOAuth (hd=chula.ac.th hint)
SB-->>App: JWT (sub = auth.users.id)
App->>DB: SELECT profiles WHERE id = uid
Note over DB: handle_new_user trigger<br/>auto-inserts profiles row<br/>role='member' if absent<br/>also enforces Chula domain
DB-->>App: profile row (role, division, ...)
App-->>U: AuthProvider state ready

profiles.role is the canonical role column. 9 values:

RoleWhoPowers
memberDefault for every new signupSubmit project · view own drafts · public reads
chairปธ.ฝ่าย / ปธ.ชมรมReview projects within own division/club · queue access
vpอุปนายกReview (vp slot) · board access
secretaryเลขานุการReview · board access
presidentนายกสโมApprove/reject at “ปธ.สโม” step · LINE-notifies on transitions
advisorอ.ที่ปรึกษาโครงการApprove/reject at “advisor” step
asst_deanผู้ช่วยคณบดีฝ่ายกิจการนิสิตApprove/reject at “ผู้ช่วยคณบดี” step
deanคณบดีApprove/reject at “คณบดี” step
adminSite admins (1-2 people)Promote/demote roles · edit divisions/clubs/events · CMS

Role groups are exported from src/components/ProtectedRoute.tsx:

  • BOARD_ROLES = ['admin', 'president', 'vp', 'secretary', 'chair']
  • APPROVAL_ROLES = BOARD_ROLES + ['advisor', 'asst_dean', 'dean']

A DB trigger (added in 0004_role_lockdown.sql) blocks any UPDATE to profiles.role unless the calling user already has role = 'admin'. To bootstrap the very first admin you must temporarily disable the trigger as a DB superuser; see CONTRIBUTING.md §“Adding a new admin”.

Common patterns observed across supabase/migrations/:

  • Read-public-write-admin (divisions, clubs, events, shop_products): anon SELECT, only admin/chair/president mutate
  • Read-authenticated (profiles): logged-in users see all profile rows (so admin views and comment authors render)
  • Read-own-or-public-status (projects): owners see their own drafts; everyone sees non-draft rows
  • Insert-by-author (approval_steps, project_comments, project_documents): only the actor can write
  • Read-own (push_subscriptions, integration_tokens, meeting_poll_responses): scoped to auth.uid()

Every UPDATE policy carries an explicit WITH CHECK clause (fixed in 0003_fix_rls_with_check.sql after a Phase 1 smoke caught the gotcha — without WITH CHECK, Postgres falls back to the USING clause for new-row validation and silently blocks legitimate state transitions). Subsequent rounds (0036, 0038, 0039, 0041) refactored policies to address Supabase performance advisors (RLS initplan + select overlap + scoping personal-data tables to authenticated).


Declared in src/App.tsx. Every page is React.lazy-imported.

PathPageNotes
/HomeHero · IG strip · announcements
/aboutAboutSMO board · faculty leadership
/about/boardBoardHistoryPast SMO terms
/about/board/:termBoardHistorySpecific term snapshot
/clubsClubsGrid of all 22 clubs
/clubs/:slugClubDetailSingle club · linked feeds
/eventsEvents12-month calendar
/galleryGalleryAggregated event photos
/wheelWheelSpin-wheel utility
/poll/newPollCreateCreate meeting poll (anyone)
/poll/:slugPollViewVote on a poll (real-time tally)
/chatChatAI chat (auth-gated inside the page)
/chat/:conversationIdChatResume conversation
/docsDocsIn-app help
/changelogChangelogPublic-facing user changelog
/privacyPrivacyRequired for Google OAuth verification
/termsTermsRequired for Google OAuth verification
/wellbeingWellbeingMental-health resources · no analytics hooks
/servicesServicesHub for Submit · Chat · Shop · Wheel · Poll · …
/shopShopMarketplace storefront
/shop/:clubSlug/:productSlugShopProductSingle product page
/cartCartCart contents (local)
/submitSubmitProject proposal wizard (auth check inside)
/newsNewsNews list
/news/:slugNewsPostSingle news post
/loginLogin3-mode (password · magic link · Google)
PathPageRoles
/meProfileAny logged-in
/me/transcriptTranscriptAny logged-in (Activity Transcript)
/me/ordersMyOrdersAny logged-in
/me/orders/:idMyOrdersAny logged-in
/my-draftsMyDraftsAny logged-in
/project/:idProjectDetailAny logged-in (page does fine-grained check)
/checkoutCheckoutAny logged-in
/events/:id/checkinCheckInAny logged-in
/events/:id/photosEventPhotosAny logged-in · upload gated by attendance row
/queueApprovalQueueAPPROVAL_ROLES
/analyticsAnalyticsBOARD_ROLES
/dashboardDashboardpresident, admin, vp
/admin/events/:id/qrEventQRBOARD_ROLES
/admin/shopAdminShopadmin, chair, president
/admin/ordersAdminOrdersadmin, chair, president
/adminAdminadmin only
/admin/auditAdminAuditLogadmin only
/admin/integrationsAdminIntegrationsadmin only · Google OAuth callback target

Catch-all * renders NotFound.


Live function list (verified via mcp__supabase-cuvetsmo__list_edge_functions):

Slugverify_jwtTriggerWhat it does
notify-status-changetruePostgres webhook on UPDATE projectsFan-out to LINE Messaging API + Discord webhook on status transitions
ai-chattrueFrontend POST from /chatProxies to Groq (Llama 3.3 70B) with OpenAlex tool calling for paper citations · streams SSE · provider chain Groq → Cerebras → OpenRouter fallback
ig-harvesttruepg_cron (3 schedules)Scrapes public Instagram profile pages to refresh ig_feed_cache and ig-thumbnails storage bucket
clubs-logo-refreshfalseManual / admin-triggeredRefreshes the clubs-logos storage bucket from canonical sources (deployed on server but source missing from repo — flag for restoration)
google-oauth-callbacktrueBrowser redirect from Google after consentExchanges authorization code for tokens · stores in integration_tokens
google-calendar-pushtrueFrontend action / admin triggerReads integration_tokens · pushes events rows to the admin’s Google Calendar

Verified via SELECT jobname, schedule FROM cron.job:

JobScheduleURL
ig-harvest-cuvetography-priority40 */6 * * * (every 6h)/ig-harvest?handle=cuvetography
ig-harvest-cuvetsmo-priority10 */6 * * * (every 6h, offset)/ig-harvest?handle=cuvetsmo
ig-harvest-hourly17 * * * * (every hour at :17)/ig-harvest (full sweep)

Note: the cron rows currently embed the anon JWT in plaintext as Authorization headers. This is acceptable because the anon key is public anyway, but rotating it requires rewriting the cron rows.

Source: supabase/functions/. One-off setup notes live alongside the function (e.g. supabase/functions/notify-status-change/SETUP.md).


This repo ships an .mcp.json so Claude Code / Cursor / any MCP-aware IDE can introspect and manipulate the deployed services without leaving the editor. Five servers are wired:

ServerWhat it gives you
supabase-cuvetsmoDB introspection (list_tables, execute_sql, list_migrations), edge function deploy, log streaming · uses a project-scoped PAT
vercelDeploy status, env vars, domain config, logs · uses a user PAT
playwrightBrowser automation (snapshot, click, fill) for e2e debugging
lazyweb257k UI-pattern reference screenshots · /design-research, /design-improve skill workflows
figmaPull design tokens / variables from Palm’s Figma file

Security: .mcp.json contains real tokens. It is gitignored. The committed copy is .mcp.json.example if you need to bootstrap. Treat tokens like you would .env.


Listed live from storage.buckets:

BucketPublicPurposeRLS pattern
avatarsyesProfile photos · <user_id>/avatar-<ts>.<ext>uploader = auth.uid()
clubs-logosyesClub brand assets · <slug>/logo.pngadmin/chair write
event-photosyesPublic photo gallery · <event_id>/<filename>uploader must have event_attendance row
ig-thumbnailsyesCached IG thumbnails populated by ig-harvestservice-role-only writes
project-documentsyesReceipts / photos / signed letters per project · <project_id>/<uploader_id>/<file>uploader = auth.uid() AND row references uploader’s project
shop-payment-proofsnoSlip uploads · privatebuyer-only read
shop-productsyesProduct images · <club_slug>/<product_slug>/...admin/chair write

Detailed bucket-RLS rules are in 0005_storage_buckets.sql and 0033_clubs_logos_storage_rls.sql.


src/pages/Submit.tsx is an 8-section form that mirrors the official CUVET 1.0 template (see sa.chula.ac.th). Schema is defined in src/lib/projectFormSchema.ts.

  • Auto-save → localStorage keyed by draft id
  • “Save to cloud” → INSERT/UPDATE on projects (RLS: owner can write only when status='draft')
  • “Download .docx” lazy-imports the docx package and calls src/lib/docxFullProposal.ts
  • “Submit for review” sets status='submitted-to-chair' and triggers the notify-status-change webhook

16-state machine for projects.status:

draft
└─ submitted-to-chair ──→ chair-approved ──→ advisor-approved
└─ assist-dean-approved ──→ dean-approved
└─ in-progress ──→ completed ──→ post-report-submitted ──→ archived
At any review step: ──→ rejected

Owner transitions (draft → submitted-to-chair, completed → post-report-submitted) are gated by projects_update_own_draft. Reviewer transitions are gated by projects_update_reviewer which checks the actor’s role matches the next step.

Implementation: src/pages/ApprovalQueue.tsx filters by role + next-status. src/pages/ProjectDetail.tsx renders the approve/reject buttons and writes approval_steps.

flowchart LR
Cron["pg_cron<br/>hourly + 2x daily"] --> Fn["ig-harvest Edge Fn"]
Fn -->|fetch profile JSON| IG["instagram.com/&lt;handle&gt;/?__a=1"]
Fn -->|store metadata| Cache["ig_feed_cache (Postgres)"]
Fn -->|fetch + reupload thumbnails| Bucket["ig-thumbnails (storage)"]
SPA["/ ° /clubs ° /clubs/:slug"] -->|read cache| Cache
SPA --> Bucket

The function is rate-limit sensitive — IG throttles aggressive scraping. The split 3-schedule design (full sweep hourly + per-handle priority every 6h) was tuned for that.

supabase/functions/ai-chat/index.ts is a multi-provider, multi-round tool-call loop:

  1. Authenticated POST from /chat with messages array
  2. Inject USER CONTEXT (role · division · cohort) into the system prompt
  3. Non-streaming Groq call up to 3 rounds — if tool_calls present, run search_openalex and search_docs in parallel, append results, re-call
  4. When the model returns text instead of tool calls, re-call with stream: true and forward each delta as an SSE delta event
  5. Provider fallback chain: Groq → Cerebras → OpenRouter on 429 / 5xx
  6. Persist conversation + messages to ai_chat_conversations / ai_chat_messages for resume

See §4 sequence diagram. Three sign-in modes (email/password, magic link, Google OAuth) all enforce the Chula domain at three layers: client-side check in auth.tsx, Supabase Auth allow-list, and DB trigger on profiles insert.

The OAuth dance lives in two pieces:

  • Browser → /admin/integrations initiates signInWithOAuth({ provider: 'google', scopes: 'openid email profile https://www.googleapis.com/auth/calendar.events' })
  • Google redirects to google-oauth-callback Edge Function, which exchanges the code, stores the refresh token in integration_tokens (encrypted column), and bounces the user back to /admin/integrations with success state
  • Admin clicks “Push to Calendar” → google-calendar-push Edge Function reads integration_tokens, refreshes access token, calls Calendar API events.insert for each row in events

Scope openid MUST be first — without it Google omits id_token (a known gotcha · see Palm’s memory feedback_oauth-openid-scope-required).


After the Phase 5 code-split:

  • Initial chunk ~250 KB gzip: React + react-router + Tailwind + AuthProvider + Header/Footer
  • Per-page chunks lazy via React.lazy — each route fetches on first navigation
  • docx chunk ~110 KB gzip: only loaded when user clicks “Download .docx”
  • Service worker precaches the initial shell · NetworkFirst for Supabase REST · skipWaiting=true so refreshes propagate fast

Build config: vite.config.ts. ES2018 target keeps Safari 12 and older Android Chrome alive.


  • clubs-logo-refresh Edge Function is deployed but its source is missing from supabase/functions/ in the repo. Pull it down via npx supabase functions download clubs-logo-refresh before any teardown/rebuild of the project.
  • _RUN_PENDING_2026-05-09.sql is a one-off catch-up bundle living in the migrations folder. Future contributors should not edit it; it exists for the Phase 2 re-bootstrap event.
  • Faculty leadership (พี่ปุ๋ย, พี่หริ, etc.) lives in faculty_static rather than profiles because faculty don’t sign up. Updates require an admin running SQL.
  • groupId for LINE notifications is currently Palm’s DM. The board-group switch is held pending นายกสโม 69 buy-in (see project_webcuvetsmo-line-target-switch memory).
  • Activity transcript uses event_attendance but the export-PDF surface in src/pages/Transcript.tsx is still feature-flagged behind low row counts.
  • alumni_profiles is provisioned (Phase 6 starter) but currently has zero rows in production.

  • Onboarding a new contributor: see Developer Onboarding
  • Annual handover ritual: see Successor Guide
  • SSO design (cross-app identity): see SSO Design
  • Operational runbooks (incidents, cost spikes) live in the docs/ folder of the source repo