Welcome to Pawtograder - Developer Guide
Pawtograder is a Next.js app on top of Supabase. Nearly everything a contributor needs to understand up front is about one thing: access control is enforced in Postgres, not in the application, so a feature is only as correct as its row-level security policies. Start with Local setup to get a stack running, then read the access-control model below before you write a query.Getting Started
Local Setup
Run Pawtograder against staging or a full local Supabase stack, plus the environment variables, CI checks, and project structure.
CLI Interface
Complete reference for the operator CLI: course and assignment management, repository maintenance, grading allocation, and student-data exports.
MCP Server
The Model Context Protocol edge function: endpoint, wire protocol, token and RLS model, and the 16 read-only tools.
Surveys
Database schema, API, and implementation details for the survey system.
Polls
Real-time polling system architecture, database schema, and hooks.
Monitoring and Metrics
Prometheus scrape endpoints, metric families, and the Grafana dashboards in the repository.
Tech stack
Versions come from
package.json. For the directory-by-directory layout, see Project structure; the one entry worth calling out here is lib/, which holds the data-access layer described below.
Access control
Row-level security
Access control lives in Postgres. Tables carry RLS policies that decide what each user can read and write based on their role in a class, and both the web app and the edge functions query as the signed-in user so those policies apply. Policies live in the migrations undersupabase/migrations/, and most of their logic sits in SQL helpers such as authorizeforclassinstructor and authorizeforclassgrader.
Two consequences for anything you build:
- A missing policy fails closed and looks like a bug in your feature. If a query returns nothing, check the policy before the code.
- A privileged path is not privileged because the UI hides it. Anything reachable with a service-role client bypasses RLS entirely, which is why the MCP server confines its service-role use to the authentication layer.
User roles
user_roles.role is an app_role, and that enum has four values (utils/supabase/SupabaseTypes.d.ts:13949, :14155):
student, grader, and instructor are per class, so the same user can be an instructor in one course and a student in another. admin is not scoped that way in practice: the dashboard gate accepts any admin row for the user and ignores its class_id (app/admin/layout.tsx:41-50).
Profiles
Every enrollment carries two rows inprofiles, and user_roles points at both through private_profile_id and public_profile_id (hooks/useClassProfiles.tsx:24-25). Both columns are unique, so each profile belongs to exactly one enrollment, which is why several foreign keys reference user_roles(private_profile_id) rather than profiles(id).
Both are created together at enrollment (
supabase/functions/_shared/EnrollmentUtils.ts:152-158 and :176-183). Only the private profile’s name is user-editable, as the preferred name in the user menu (app/course/[course_id]/UserMenu.tsx:425-438).
Which profile a row references is what decides whether the record is attributable, so pick deliberately when you add a column:
- Always private. Submissions (
supabase/migrations/20250330003141_remote_schema.sql:2412-2413), gradebook rows (supabase/migrations/20250614231720_gradebook.sql:197), and help requests. - Chosen per post. A discussion thread or chat message stores the public profile when the author opts into anonymity and the private profile otherwise (
app/course/[course_id]/discussion/new/page.tsx:97,components/ui/message-input.tsx:129). Reads therefore have to match against both ids. Graders’ rubric and line comments switch to the public profile when the assignment hasgrader_pseudonymous_modeon (hooks/useAssignment.tsx:73-76). - Always public. Poll responses (
app/poll/[course_id]/page.tsx:82-98).
is_private_profile also serves as a discriminator for “one row per enrolled person” when you need a roster without joining user_roles (hooks/useCourseController.tsx:133).
Data access
Prefer a Postgres RPC over a Next.js server action or an edge function for data operations. Edge functions are for integrations that need an external API, such as GitHub, Discord, or AWS Chime, not for general data access. Components do not query Supabase directly.TableController (lib/TableController.ts) caches and loads data scoped to a context such as a course, assignment, submission, or discussion thread, and the realtime controllers in lib/ extend it for live updates. Components consume all of it through the hooks in hooks/.
utils/supabase/SupabaseTypes.d.ts is generated and copied into supabase/functions/_shared/. Regenerate it with npm run client-local after a schema change; do not hand-edit it.
Contributing
- Fork the repository and create a feature branch.
- Make your changes. Regenerate types with
npm run client-localif you touched the schema. - Run
npm run formatbefore committing. CI rejects unformatted code (AGENTS.md:42). - Run the checks CI runs:
npm run lint,charts/pawtograder/tests/render-guardrails.sh, andnpm run test:functions(lint.yml:33,:47,:60). - Run the tests your change affects:
npm testfor Jest,npx playwright testfor end-to-end. - Open a pull request.
No workflow runs a standalone TypeScript typecheck, and the full Jest suite is not part of CI. See Continuous integration for what each workflow actually runs.