Skip to main content

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 under supabase/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).
Several role gates ask whether a user holds a role in any class rather than in the class the request is about. The admin dashboard gate above is one; the MCP server’s pre-tool check is another. When you add a gate, decide deliberately which of the two you mean, and rely on RLS for the per-class answer.

Profiles

Every enrollment carries two rows in profiles, 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 has grader_pseudonymous_mode on (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).
create_class_with_admin gives the creating admin their real name on both profiles rather than generating a pseudonym (supabase/migrations/20250820185937_admin_portal_system.sql:81-100). Do not treat a public profile name as guaranteed to be anonymous.

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

  1. Fork the repository and create a feature branch.
  2. Make your changes. Regenerate types with npm run client-local if you touched the schema.
  3. Run npm run format before committing. CI rejects unformatted code (AGENTS.md:42).
  4. Run the checks CI runs: npm run lint, charts/pawtograder/tests/render-guardrails.sh, and npm run test:functions (lint.yml:33, :47, :60).
  5. Run the tests your change affects: npm test for Jest, npx playwright test for end-to-end.
  6. 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.