Built incrementally. This bundle covers steps 1, 2, 3a, and 3b-core:
- Step 1: Drizzle schema for all features, Auth.js with three providers, Postgres rate limiting, role-checked session helpers, Railway deploy config.
- Step 2: Classes CRUD, roster management, single + bulk-CSV invites, student self-registration via invite link or join code, password reset flow, the authed app shell, dashboard, trash with 30-day restore.
- Step 3a: Essay assignment lifecycle — create / edit / duplicate / copy-to-class / publish / unpublish / soft-delete. Student submission with Tiptap rich text editor, PDF/DOCX file upload to S3-compatible storage (Cloudflare R2), 30-second autosave, manual save, paste detection on >100-char paste events, full version history, submission lifecycle (NOT_STARTED → IN_PROGRESS → SUBMITTED/LATE), originality stats helpers.
- Step 3b-core: Grading UI (score + general feedback editor), anchored
highlight comments (select passage → comment), comment threads with
resolve/unresolve, submission state actions (return/missing/excuse/reopen),
posting policy (grades hidden until posted), per-student per-assignment
overrides for due-date and close-date (IEP / 504 / makeup), attachment
download via signed URLs, originality "writing signals" panel surfacing
the stats from 3a. Plus three deferred fixes from 3a:
tokenVersionfor JWT revocation, walker-based empty-body detection, and pending invites revoked when their class is soft-deleted.
Step 3c (next bundle) will add: rubrics (define + attach + click-grade
- auto-score), bulk actions (download all submissions as zip, mark missing en masse, post all grades), and the Postgres-backed job worker.
# 1. Install
pnpm install
# 2. Env
cp .env.example .env
# At minimum:
# - AUTH_SECRET (openssl rand -base64 32)
# - AUTH_URL (http://localhost:3000 for dev)
# - SEED_OWNER_EMAIL (your email — becomes the teacher account)
# Optional but recommended:
# - RESEND_API_KEY + EMAIL_FROM (without them, email logs to console)
# - GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET
# 3. Database (local)
docker compose up -d
pnpm db:push # apply schema
pnpm db:seed # creates your org + teacher account
# 4. Run
pnpm devVisit http://localhost:3000. Click "Email me a sign-in link" with the email
in SEED_OWNER_EMAIL. If you didn't set up Resend, check your terminal — the
magic link prints to stdout in dev.
Run these in order. Each should pass before moving on.
-
/loginrenders, switches between magic-link and password tabs - Magic link with the seed email → check-email page; link in terminal works; lands on
/dashboard -
/login/reset→ submit your email → reset email logs to terminal → link works → password set → redirected to/login→ can sign in with the new password - Wrong password shows "Email or password is incorrect" (no enumeration of whether the email exists)
- Five failed password attempts from the same IP within a minute get rate-limited
- Google OAuth button works (if configured)
-
/dashboardshows "Create a class" when empty -
/classes→ "New class" → fill title → creates → redirects to class detail - Class detail shows student count (0), banner color, join code
-
/classes/[id]/settingslets you edit title/term/description/banner, save persists - Regenerating join code produces a different 8-char code with no
0/O/1/I/L - Archive → class disappears from default list, shows in
/classes?archived=1 - Unarchive from settings restores it
- Delete (two-click confirmation) sends class to
/trash -
/trashshows days-left, restore button brings the class back to/classes
- In roster: single invite → an entry appears under "Pending invitations"
- Email logs to terminal (or to inbox if Resend configured) with a link
- Open the link in an incognito window →
/registerpre-filled with the invite email - Create the student account (password ≥ 10 chars) → auto-signs in → lands on the class
- Roster now shows the student under "Enrolled"; pending invite is gone
- Roster → Bulk CSV tab → paste:
name,email Alice Chen,alice@example.com Bob Park,bob@example.com not-valid-line,broken - Preview shows "2 valid rows, 1 error" with "Invalid email: broken" in expanded errors
- Send invites → summary shows "2 invited"
- Re-running the same CSV shows "2 already invited" instead of duplicates
-
.csvfile upload works equivalently - CSV with UTF-8 BOM and CRLF endings parses cleanly (covered by unit tests)
- Incognito →
/register→ fill name/email/password/joinCode → enrolled - Sign in as that student → see the class on
/dashboard -
/joinwith the same code while signed in → idempotent, "already enrolled" - Regenerating the code in settings invalidates the old one immediately
- Sort ascending/descending by clicking column headers
- Search filters by name + email
- Click a student → profile page with private notes textarea
- Save notes → reload → persist
- Change status to Deactivated → "Deactivated" badge appears
- Remove student (two-click confirm) → disappears from roster
- Re-invite the removed student → reactivated, not duplicated
- Hit
/api/classes/<some-other-teacher-id>with curl using your session cookie → 404 - Sign out, hit
/api/classesdirectly → 401 - Sign in as a student →
/classes/[id]/settings→ 404 (no leak of existence) - Student can see classes they're enrolled in but cannot reach roster or settings
- On a phone (or browser at 380px wide): sidebar collapses, hamburger toggles, focus rings visible
pnpm test # 15 CSV + 7 token + 13 originality cases
pnpm test:e2e # set TEST_TEACHER_EMAIL + TEST_TEACHER_PASSWORD firstThese exercise the new assignment + submission flow. R2 is required for the
upload tests (set S3_* in your .env); the rest work without it.
-
/classes/:id/assignmentsshows "New assignment" button, no items yet - Create a draft: title only, save as draft → lands on assignment detail with
DRAFTbadge - Edit the assignment → set instructions (try bold/italic/heading), points, dueAt → save
- Publish from the detail page → badge flips to
PUBLISHED - Duplicate from the detail page → new assignment in
DRAFT, time fields cleared, title"(copy)"appended - Copy-to-class via API:
curl -X POST /api/classes/:id/assignments/:aid/copy-to -d '{"targetClassId":"..."}'→ assignment appears in the target class asDRAFT - Soft-delete the assignment → gone from list →
PUT /api/classes/:id/assignments/:aidrestores it - Try to delete twice (already in trash) → idempotent: succeeds
- Sign in as a student → assignment list shows only
PUBLISHEDitems - Set
availableFromto one hour in the future → assignment hidden from student - Set
availableFromto one hour in the past → visible again
- Click "Start submission" → lands on
/submitwith an empty editor - Type some text, wait 30s → "Saved" indicator
- Press Cmd/Ctrl+S → immediate save
- Paste >100 chars from another document → immediate save with
fromPaste=true(verify withselect * from submission_versions order by created_at desc limit 1;) - Reload the page → text persists, status is
IN_PROGRESS - Upload a PDF → appears in attachment list with size
- Try to upload a
.txtfile → client-side rejection ("Only PDF or DOCX") - Try to upload a
.exerenamed to.pdf→ server-side rejection (magic-byte check) with "File contents don't match" - Try to upload a 30MB file → rejection: "File too large"
- Click "Submit for grading" once → button changes to "Click to confirm submission"
- Click again → redirects to
/submit/preview, status isSUBMITTED(orLATEif pastdueAt) - Try to navigate back to
/submit→ message: "Already submitted" - Try to PATCH the submission via the API after submitting → 409
SUBMISSION_LOCKED
- Create assignment with
dueAt1 minute in the past, late policy = ACCEPT → student submits → status =LATE - Create assignment with
availableUntil1 minute in the past, late policy = REJECT → student visits/submit→ "Submissions closed" page (no editor) - Same closed assignment, hit
/api/.../submissions/startdirectly → 404 (student access blocked)
- After ~3 minutes of typing,
select count(*) from submission_versions where submission_id=:sid;→ roughly equals minutes spent (one per 30s, plus paste events) - After a single paste,
select from_paste, paste_char_count from submission_versions order by created_at desc limit 1;→from_paste=true, char count matches what was pasted
- As student with a future assignment due in 3 days → "Upcoming this week" lists it
- As teacher after a student submits → "Needs grading" shows the class with
N submissions to grade
The grading UI is the centerpiece; everything else hangs off it. Run as teacher unless noted.
- On an assignment with a SUBMITTED student, click "View submissions" → table lists all enrolled students, the submitter near the top with "needs grading"
- Filter "Needs grading" → only the submitters appear; "Missing / not started" → only the non-submitters; "Graded" → empty until you grade someone
- Click "Open" on a SUBMITTED row → lands on the grader page
- Enter a score, save → "Saved" indicator, submission row in the list now shows score with
(hidden) - As that student in another browser → assignment page still says "Submitted", no score visible
- Back as teacher → click "Save & post" → indicator flips to "Posted"
- As student → score is now visible on the assignment detail page AND on
/submit/previewalong with the rich-text feedback
- In the grader, select a passage of the submission body → an inline composer appears with the quoted passage shown
- Type a comment, click "Post comment" → comment appears in the right sidebar with the quote in italic above it, anchored comments sort to top
- Hover the comment in the sidebar (no highlight on the body yet — see known limitations)
- Click "Resolve" → comment dims and moves to the bottom; "Unresolve" reverses it
- Write a "General feedback" box comment without selecting anything → appears in the sidebar without a quote block
- Verify in DB:
select anchor_start, anchor_end, anchor_version_id from submission_comments order by created_at desc limit 1;for an anchored comment — all three are populated, anchor_version_id matches the latest version row - As student (after posting grade) → posted comments visible on
/submit/previewwith the quote; un-posted comments hidden
- Click "Return" on a graded submission → status flips to RETURNED, grade posts automatically, student can no longer edit
- Click "Reopen" → status flips to IN_PROGRESS, submittedAt cleared, postedAt cleared; as student you can edit and resubmit
- On a student who never submitted, click "Mark missing" → status MISSING; clicking it on an already-SUBMITTED row gives 409 INVALID_TRANSITION
- Click "Excuse" → status EXCUSED; gradebook math (step 5) will exclude this
- Roster → click a student → "Assignments & overrides" table loads with all assignments
- Click "Add override" on a future assignment → datetime inputs appear → set a custom due date one week later → Save
- As that student → assignment detail page shows the OVERRIDE due date, not the assignment default
- As teacher, set the assignment's default
availableUntilto 1 minute ago with REJECT policy. As that student → submit page still works because the override extendsavailableUntil(if you set it). Without the override, you'd see "Submissions closed." - Click "Remove" on the override → confirm prompt → override row deleted; default dates resume
- Sign in as a student in Browser A. As teacher in Browser B:
curl -X POST -H "Cookie: <teacher-session-cookie>" /api/users/:studentUserId/force-logout→ 200 - In Browser A, navigate anywhere → session is gone, you're redirected to
/login - Reset your own password via
/login/reset→ after setting new password, any other browser session for that account is also kicked
- Send a single invite to
test@example.com. Don't accept it. - Soft-delete the class → audit log shows
invitesRevoked: 1 - Open the invite email link → 410 GONE, "Invite revoked" page (or JSON if hit raw)
- As teacher, open the grader for a submission with an attachment → click the filename → opens the file in a new tab (302 redirect to a signed R2 URL valid for 5 minutes)
- As a different student in the same class →
GET /api/attachments/:id/downloadreturns 404 (auth gate honors submission ownership)
- Rubrics + bulk actions + job worker — step 3c
- Quizzes + QTI — step 4
- Gradebook math — step 5
- Announcements, messaging, notifications beyond invites — step 6
- Calendar, accessibility audit polish — step 7
.imsccCanvas import — step 8- Export-everything zip + full audit log UI — step 9
The schema for all of these is already in place from step 1.
Tailwind v3, not v4. v4's CSS-first config is nice but the docs are still catching up. You asked for boring + well-documented; v3 wins.
No shadcn CLI. Button, Input, Label, modal are small enough to own. We can add radix back when we need Combobox/Popover.
Auth provider is Nodemailer-with-custom-sender rather than Resend.
Auth.js v5's Resend provider import path has churned across betas; calling
our own sendEmail from the Nodemailer provider works consistently and
keeps a single email codepath.
teacherNotes on the enrollment row, not a separate table. Per our
conversation. If we later need audited history of notes we can add a
note_versions table; the column stays where it is.
Removed students get status=REMOVED AND deletedAt set. The two flags
overlap. The distinction lets us hide them from default queries while
keeping them recoverable. Belt + braces.
- Email verification for join-code-path users. Their
emailVerifiedis null. The notification system in step 6 won't email them until we add a "verify your email" flow on first login. ~1 hour task. - DOCX magic-byte check verifies the ZIP header only. All Office files
are zips, so a
.ziprenamed to.docxwould pass when paired with the right MIME header. We'd need to parse the manifest to be sure. The MIME-type gate plus the extension check makes this a narrow edge case. - Anchored comment highlights don't render in-line on the submission
body. The comment appears in the sidebar with the quoted passage, but
the original passage in the body isn't visually highlighted. Doing this
means walking the rendered DOM and wrapping text nodes between
anchorStartandanchorEndwith a<mark>— possible but adds DOM complexity and risks breaking Tiptap's render. Deferred to step 3c. - DOM Range → character offset walker (
src/lib/grading/range.ts) is logic-only, no unit tests. Needs jsdom or a browser-test runner to exercise. Verified by inspection but unproven against edge cases like selections that cross multiple block boundaries. applyOverrideruns one extra query per assignment read. For the single-teacher case (dozens of students) this is fine; if scale grows we'd batch overrides into the initial assignment query or cache per request.- Dashboard "Upcoming this week" doesn't apply overrides. A student with an extended due date will still see the assignment under its default due date in the dashboard, even though the submission flow correctly honors the override. Cosmetic for v1, fixable in step 5 alongside gradebook math.
tokenVersioncolumn added to users; JWT carriestvclaim; session callback rejects mismatches. Bumped on password reset; teacher can force logout viaPOST /api/users/:userId/force-logout.- Empty-body detection replaced with
isTiptapDocEmptywalker that counts non-whitespace text chars across the Tiptap tree. - Class soft-delete now revokes pending invites (sets
revoked_at); the accept endpoint returns clear errors (410INVITE_REVOKED, 410INVITE_EXPIRED, 409INVITE_ALREADY_USED) instead of silent 404s.
- Railway ~$5/mo for app + Postgres
- Cloudflare R2 free up to 10 GB and free egress (essay PDFs/DOCXs land here in step 3a)
- Resend free 3000 emails/month — plenty for a single teacher