Skip to content

Commit 66548e3

Browse files
authored
Merge #385: bounded plans + Explore + course aliasing + 2 audits + CI fixes (local-main → main)
## Summary by Sourcery Harden authentication and content-write routes, improve cross-platform layout and motion behavior, and polish core collaboration flows around study groups, messaging, notes, library, Scholar, and onboarding. New Features: - Send notifications to note owners when their notes are starred. - Notify study-group members when sessions are cancelled or rescheduled. - Allow sharing Scholar papers into study groups as link resources from the paper page. - Add a mobile bottom navigation bar for authenticated users on phone viewports. Bug Fixes: - Fix Scholar discovery and similar-papers queries that previously returned empty lists due to invalid non-null filters. - Ensure passkey (WebAuthn) login issues a proper session compatible with step-up MFA checks, restoring access to admin-gated routes. - Apply an Origin allowlist to WebAuthn and sheetLab routes to close CSRF-related gaps. - Prevent saver-mode preference changes from being ignored until the next session refresh by syncing saved preferences back into the session user. - Restore achievement-unlock notifications in the bell by avoiding self-notify suppression. - Fix study-group list performance by batching per-group aggregates instead of issuing many queries per group. - Show error toasts when joining groups, following users, or starring notes fail instead of silently appearing to succeed. - Prevent invalid material and assignment IDs from being processed by validating numeric IDs before archive/delete operations. - Ensure study-group background image previews resolve the same URL as the header image. - Fix iOS Safari auto-zoom on form inputs by enforcing a minimum font size on touch viewports. - Make conversation lists and security-alerts settings show skeleton loaders instead of bare loading text. Enhancements: - Introduce canonical spacing and chrome tokens and migrate key layouts and sidebars to use them for consistent rhythm and sticky offsets across pages. - Standardize sticky offsets, split-panel heights, and safe-area handling (e.g., AiBubble, messages split panel, sidebars) using navbar height and dynamic viewport units for better mobile behavior. - Honor the reduced-motion escape hatch via data-motion="keep" so critical spinners can animate while other motion is suppressed. - Add focus-trapped, accessible modals for group create/edit, note conflict-compare, and destructive library actions, improving keyboard and screen-reader behavior. - Replace window.confirm flows for deleting shelves and bookmarks with styled, focus-trapped confirmation dialogs. - Refine feed, sidebar, skeleton, and settings spacing to reduce layout shifts and align gaps to the shared spacing scale. - Parallelize unread-count computations in messaging routes to avoid N+1 latency on large inboxes. - Lazy-load the onboarding tour (react-joyride) so its bundle is fetched only when a tour actually runs. - Clear Scholar "saved" cache entries after toggling shelf state so the Saved view can’t render stale lists. - Align various authenticated pages (Hub AI, Study Groups, Notifications, Playground) with breadcrumb-based nav for consistency. Documentation: - Extend the public release log with detailed wave-12.14–12.18 entries documenting security, performance, UX, and audit follow-ups. Tests: - Update WebAuthn unit tests to assert session issuance via the canonical helper and ensure sessions are not created on failed authentication. - Adjust study-group route tests to accommodate new batched aggregation queries. Chores: - Remove unused design-v2 feature flags and associated network requests to simplify config and reduce per-page flag fetches.
2 parents 2c5f49e + 192a976 commit 66548e3

209 files changed

Lines changed: 10018 additions & 4453 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/quality-gates.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ jobs:
5151
- name: Run Lighthouse CI
5252
uses: treosh/lighthouse-ci-action@v12
5353
with:
54-
configPath: frontend/studyhub-app/lighthouse.config.js
54+
configPath: frontend/studyhub-app/lighthouse.config.cjs
5555
uploadArtifacts: true
5656

5757
bundle-size:

CHANGELOG.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,50 @@ For internal cycle-by-cycle release notes, see `docs/release-log.md` (tracked) a
88

99
## [Unreleased]
1010

11+
### Security (2026-05-31 — 2nd audit pass, wave-12.24)
12+
13+
- **Study-group discussion replies now require active membership to edit/delete.** A banned or removed member previously kept the ability to edit or delete their own old replies in a private group (the active-membership gate covered the post handlers but had missed the reply handlers). Now both `updateReply` and `deleteReply` require active membership.
14+
- Explore hardening: an unrecognized `?topic=` returns an empty result instead of all cross-school content; the feature kill switch drops stale cached content when flipped off; cross-school search expansion no longer runs on the private "my sheets" path; the topic fuzzy-match query now uses the trigram index.
15+
16+
### Added (2026-05-31 — cross-school discovery, wave-12.23)
17+
18+
- **Self-learner Explore (`/explore`).** A cross-school discovery page — topic chips, a "Trending this week" shelf, and Sheets / Notes / Study-groups shelves drawn from every school. Read-only, block-filtered, behind `flag_explore_tab` (fail-closed). Backend: `GET /api/explore/{sheets,trending,notes,study-groups,topics}`.
19+
- **Course aliasing — "Equivalent at other schools."** A curated, CIP-coded topic taxonomy (`CourseAlias` + `TopicCanonical`) maps equivalent courses across schools, so searching "intro programming" surfaces every school's version (Postgres `pg_trgm` fuzzy match), and the Sheets page shows equivalents when you filter to one course. Behind `flag_course_aliasing`. Backend: `GET /api/courses/topics`, `GET /api/courses/:id/equivalents`.
20+
21+
### Security (2026-05-31 — 10-loop adversarial audit fixes, wave-12.22)
22+
23+
- **Private study-group content is now gated on _active_ membership.** Pending (un-approved) and banned members could previously read and post in private-group discussions/resources/sessions; a new `requireActiveGroupMember` guard closes it, and replies/upvotes to hidden (removed / pending-approval) posts are blocked.
24+
- **Cross-conversation message-content leak closed.** `replyToId` on a new message is now verified to belong to the same conversation, so a participant can no longer pull a stranger's private message content into their own thread.
25+
- **"Downloads disabled" is enforced on the attachment-preview path (A6).** The preview route streamed the full original file regardless of `allowDownloads`; it now applies the same owner-or-allowed gate as the download routes.
26+
- **WebAuthn fails closed in production (A9).** `WEBAUTHN_RP_ID`/`WEBAUTHN_ORIGIN` no longer fall back to localhost in prod (which silently broke admin passkey auth); both are now `REQUIRED_IN_PRODUCTION`.
27+
- **CSRF `originAllowlist()` (A11)** added to the courses, study-status, plagiarism, and hashtags write modules. Socket cookie parser hardened against prototype-pollution keys; socket `conversationId` integer-validated.
28+
29+
### Fixed (2026-05-31 — 10-loop audit)
30+
31+
- **GDPR account deletion no longer aborts** when an optional table is absent — those cleanups moved out of the all-or-nothing transaction.
32+
- **Note edits made right before closing the tab are saved** — the `sendBeacon` autosave (POST-only) now reaches the update handler instead of 404-ing.
33+
- **Two migrations made idempotent** (teacher-materials, AiSuggestion) so a partial `migrate deploy` can be safely retried (A5).
34+
- **Optimistic-UI now reconciles with the server** on study-status, note/sheet/feed reactions, video downloadable toggle, and follow/join — a rejected write rolls back and toasts instead of silently sticking (A4).
35+
- **A12 integer guards** on sheet-comment react/delete/edit, materials body ids, pinned-sheet delete, study-status PUT.
36+
- **A11y:** focus traps on the section-picker, topic-picker, and report-group modals; keyboard-accessible announcement image thumbnails; labelled topic-picker inputs.
37+
- **Perf:** discussion lists count via `_count` instead of loading every reply/upvote row; the all-time leaderboard hydrates only the top-N and bounds its aggregate to a rolling year.
38+
39+
### Security (2026-05-31 — bounded-plan completion batch, wave-12.21)
40+
41+
- **Strict route-id parsing closes the partial-numeric hole.** New `parseRouteId()` (`core/http/validate.js`) rejects ids that `Number.parseInt` would accept partially (`"12abc" → 12`). Applied to `PATCH /api/materials/:id/archive` and `DELETE /api/materials/assignments/:id` (Codex PR-review P2). Digit-only, capped at `MAX_SAFE_INTEGER`.
42+
- **List-endpoint DoS cap.** New `parseBoundedInt(value, fallback, max)` silently clamps `?limit=` on 4 endpoints (sheets list 100; sheet-comments, feed list, feed reactions 50) — a `?limit=999999` no longer fetches unbounded rows. `parsePositiveInt` deprecated.
43+
- **Password-reset rejects same-as-current** (NIST 800-63B §5.1.1.2, OWASP ASVS V2.1.1). `POST /api/auth/reset-password` 400s with `PASSWORD_UNCHANGED` when the new password equals the current bcrypt hash; all its raw error envelopes migrated to `sendError()`.
44+
- **PII value-scrubbing in `redactObject`** (A8 defense-in-depth). Email / phone / IPv4 / SSN patterns are masked inside free-text log string values, not just known sensitive keys.
45+
- **Stripe idempotency keys** on all 11 state-changing SDK calls (checkout, customer, portal, subscription pause/resume/cancel/reactivate) — a retried call returns the cached response instead of double-charging. Checkout logs Stripe's `request_id`.
46+
- **`x-request-id` propagation** to the route-level Anthropic calls (sheet generate/edit, note summarize) for log↔dashboard correlation.
47+
48+
### Fixed (2026-05-31)
49+
50+
- **`security.headers` HSTS test** failed in any env whose `.env` lacked `R2_BUCKET_UPLOAD_BACKUP` (promoted to `REQUIRED_IN_PRODUCTION` in wave-12.11 but never added to the test's dummy-secret setup). Added the dummy.
51+
- **Stale debounce / in-flight fetch after unmount.** `AbortController` + unmount timer-cleanup on 5 effect-driven sites (library data + reader, sheet-lab reads, note-persistence timers, admin user-search) — no more `setState` after navigation.
52+
- **Duplicate unread-count polling.** Navbar bell + mobile bottom-nav badge now share one `UnreadProvider` poll instead of two independent 30s pollers.
53+
- **A11y:** focus-trap + Escape + `role="dialog"`/`aria-modal` on 4 more modals (FollowModal, NoteVersionHistory, ModerationAppealModal, AnnouncementMedia lightbox); WAI-ARIA menu keyboard nav on `NavbarUserMenu`; `<main id="main-content">` landmarks on UserProfile + Library so the skip link lands.
54+
1155
### Added (2026-05-01 rev 2)
1256

1357
- **Settings → Security → Recovery codes UI.** `RecoveryCodesSection.jsx` mounted in the existing SecurityTab. Generates 10 single-use codes via `POST /api/settings/2fa/recovery-codes/regenerate`, displays them in a forced-acknowledgement modal (Escape + backdrop disabled until the user confirms they've saved them), Copy + Download `.txt` actions. Section silently doesn't render if `flag_2fa_recovery_codes` is off (status endpoint 404).

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,7 @@ Tables with migrations (safe to query):
533533
- StudyGroup trust & safety (moderation, mute, strikes, GroupReport, GroupAppeal, GroupAuditLog, GroupBlock) — migration `20260409000002_add_group_trust_and_safety` (rewritten 2026-05-04 with full `IF NOT EXISTS` / `DO $$ EXCEPTION WHEN duplicate_object` guards per CLAUDE.md A5 — closes Bug A "Failed to load groups").
534534
- Hub AI v2 + library weekly sync — migration `20260504000001_hub_ai_v2_and_library_sync`. Adds `AiAttachment`, `UserAiStorageQuota`, `AiGlobalSpendDay`, `AiUploadIdempotency`, `LibrarySyncState` tables + `AiMessage.attachments` + `AiUsageLog.{documentCount,tokensIn,tokensOut,documentTokens,costUsdCents}`.
535535
- Scholar v1 + v1.5 — migration `20260504000002_scholar_v15`. Adds `ScholarPaper`, `ScholarPaperSearchCache`, `ScholarAnnotation`, `ScholarDiscussionThread` + `ShelfBook.sourceType` / `paperId`.
536+
- Course aliasing (G2-4) — migration `20260531000001_course_aliases`. Adds `CourseAlias` + `TopicCanonical` (CIP-coded topic taxonomy) + the `pg_trgm` extension and a GIN trigram index on `TopicCanonical.displayName`. Powers cross-school search expansion + "Equivalent at other schools". (G2-3 Explore is read-only over existing tables — no new tables.)
536537

537538
## Internal Documentation Layout (added 2026-04-30)
538539

backend/.env.example

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,8 @@ ADMIN_EMAIL=abdulrfornah@getstudyhub.org
6868
ADMIN_USERNAME=
6969
ADMIN_PASSWORD=
7070

71-
# ── OPTIONAL CONTENT MODERATION (text only) ─────────────────────
72-
# Used by backend/src/lib/moderation/moderationEngine.js (text scoring).
73-
# Distinct from ANTHROPIC_API_KEY (Hub AI).
74-
OPENAI_API_KEY=
75-
7671
# ── ANTHROPIC (Hub AI) ──────────────────────────────────────────
77-
# Required for /api/ai endpoints. Note: backend uses ANTHROPIC_API_KEY,
78-
# NOT OPENAI_API_KEY (that var is the moderation engine only).
72+
# Required for /api/ai endpoints.
7973
ANTHROPIC_API_KEY=
8074

8175
# ── GOOGLE OAUTH ────────────────────────────────────────────────

backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"preview:fixture": "node scripts/ensurePreviewFixture.js",
1818
"seed:admin": "node scripts/seedAdmin.js",
1919
"seed:flags": "node scripts/seedFeatureFlags.js",
20+
"seed:aliases": "node scripts/seedCourseAliases.js",
2021
"seed:roles-v2-flags": "node scripts/seedRolesV2Flags.js",
2122
"seed:notes-hardening-flag": "node scripts/seedNotesHardeningFlag.js",
2223
"backfill:note-version-bytes": "node scripts/backfillNoteVersionBytes.js",

backend/prisma/migrations/20260427000001_add_teacher_materials_tables/migration.sql

Lines changed: 87 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,14 @@
1919
-- - MaterialAssignment: CASCADE from both Material and Section
2020
--
2121
-- No data backfill required — all four tables are net-new.
22+
--
23+
-- Every statement is guarded (CREATE TABLE/INDEX IF NOT EXISTS, FK
24+
-- constraints wrapped in DO $$ ... EXCEPTION WHEN duplicate_object) so
25+
-- `prisma migrate deploy` is safe to retry on partial failure and is a
26+
-- no-op on an already-migrated DB (CLAUDE.md A5).
2227

2328
-- CreateTable: Section — teacher-owned cohort for grouping students.
24-
CREATE TABLE "Section" (
29+
CREATE TABLE IF NOT EXISTS "Section" (
2530
"id" SERIAL NOT NULL,
2631
"courseId" INTEGER,
2732
"teacherId" INTEGER NOT NULL,
@@ -36,7 +41,7 @@ CREATE TABLE "Section" (
3641
);
3742

3843
-- CreateTable: SectionEnrollment — student membership in a section.
39-
CREATE TABLE "SectionEnrollment" (
44+
CREATE TABLE IF NOT EXISTS "SectionEnrollment" (
4045
"id" SERIAL NOT NULL,
4146
"sectionId" INTEGER NOT NULL,
4247
"userId" INTEGER NOT NULL,
@@ -47,7 +52,7 @@ CREATE TABLE "SectionEnrollment" (
4752
);
4853

4954
-- CreateTable: Material — teacher-curated wrapper around a StudySheet or Note.
50-
CREATE TABLE "Material" (
55+
CREATE TABLE IF NOT EXISTS "Material" (
5156
"id" SERIAL NOT NULL,
5257
"teacherId" INTEGER NOT NULL,
5358
"sheetId" INTEGER,
@@ -63,7 +68,7 @@ CREATE TABLE "Material" (
6368
);
6469

6570
-- CreateTable: MaterialAssignment — join between Material and Section with metadata.
66-
CREATE TABLE "MaterialAssignment" (
71+
CREATE TABLE IF NOT EXISTS "MaterialAssignment" (
6772
"id" SERIAL NOT NULL,
6873
"materialId" INTEGER NOT NULL,
6974
"sectionId" INTEGER NOT NULL,
@@ -74,41 +79,91 @@ CREATE TABLE "MaterialAssignment" (
7479
);
7580

7681
-- Uniqueness constraints
77-
CREATE UNIQUE INDEX "Section_joinCode_key" ON "Section"("joinCode");
78-
CREATE UNIQUE INDEX "SectionEnrollment_sectionId_userId_key" ON "SectionEnrollment"("sectionId", "userId");
79-
CREATE UNIQUE INDEX "MaterialAssignment_materialId_sectionId_key" ON "MaterialAssignment"("materialId", "sectionId");
82+
CREATE UNIQUE INDEX IF NOT EXISTS "Section_joinCode_key" ON "Section"("joinCode");
83+
CREATE UNIQUE INDEX IF NOT EXISTS "SectionEnrollment_sectionId_userId_key" ON "SectionEnrollment"("sectionId", "userId");
84+
CREATE UNIQUE INDEX IF NOT EXISTS "MaterialAssignment_materialId_sectionId_key" ON "MaterialAssignment"("materialId", "sectionId");
8085

8186
-- Query indexes
82-
CREATE INDEX "Section_teacherId_archived_idx" ON "Section"("teacherId", "archived");
83-
CREATE INDEX "Section_courseId_idx" ON "Section"("courseId");
84-
CREATE INDEX "SectionEnrollment_userId_idx" ON "SectionEnrollment"("userId");
85-
CREATE INDEX "Material_teacherId_archived_idx" ON "Material"("teacherId", "archived");
86-
CREATE INDEX "Material_sheetId_idx" ON "Material"("sheetId");
87-
CREATE INDEX "Material_noteId_idx" ON "Material"("noteId");
88-
CREATE INDEX "MaterialAssignment_sectionId_dueAt_idx" ON "MaterialAssignment"("sectionId", "dueAt");
87+
CREATE INDEX IF NOT EXISTS "Section_teacherId_archived_idx" ON "Section"("teacherId", "archived");
88+
CREATE INDEX IF NOT EXISTS "Section_courseId_idx" ON "Section"("courseId");
89+
CREATE INDEX IF NOT EXISTS "SectionEnrollment_userId_idx" ON "SectionEnrollment"("userId");
90+
CREATE INDEX IF NOT EXISTS "Material_teacherId_archived_idx" ON "Material"("teacherId", "archived");
91+
CREATE INDEX IF NOT EXISTS "Material_sheetId_idx" ON "Material"("sheetId");
92+
CREATE INDEX IF NOT EXISTS "Material_noteId_idx" ON "Material"("noteId");
93+
CREATE INDEX IF NOT EXISTS "MaterialAssignment_sectionId_dueAt_idx" ON "MaterialAssignment"("sectionId", "dueAt");
8994

9095
-- Foreign keys: Section
91-
ALTER TABLE "Section"
92-
ADD CONSTRAINT "Section_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE SET NULL ON UPDATE CASCADE;
93-
ALTER TABLE "Section"
94-
ADD CONSTRAINT "Section_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
96+
DO $$
97+
BEGIN
98+
ALTER TABLE "Section"
99+
ADD CONSTRAINT "Section_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE SET NULL ON UPDATE CASCADE;
100+
EXCEPTION
101+
WHEN duplicate_object THEN NULL;
102+
END $$;
103+
104+
DO $$
105+
BEGIN
106+
ALTER TABLE "Section"
107+
ADD CONSTRAINT "Section_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
108+
EXCEPTION
109+
WHEN duplicate_object THEN NULL;
110+
END $$;
95111

96112
-- Foreign keys: SectionEnrollment
97-
ALTER TABLE "SectionEnrollment"
98-
ADD CONSTRAINT "SectionEnrollment_sectionId_fkey" FOREIGN KEY ("sectionId") REFERENCES "Section"("id") ON DELETE CASCADE ON UPDATE CASCADE;
99-
ALTER TABLE "SectionEnrollment"
100-
ADD CONSTRAINT "SectionEnrollment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
113+
DO $$
114+
BEGIN
115+
ALTER TABLE "SectionEnrollment"
116+
ADD CONSTRAINT "SectionEnrollment_sectionId_fkey" FOREIGN KEY ("sectionId") REFERENCES "Section"("id") ON DELETE CASCADE ON UPDATE CASCADE;
117+
EXCEPTION
118+
WHEN duplicate_object THEN NULL;
119+
END $$;
120+
121+
DO $$
122+
BEGIN
123+
ALTER TABLE "SectionEnrollment"
124+
ADD CONSTRAINT "SectionEnrollment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
125+
EXCEPTION
126+
WHEN duplicate_object THEN NULL;
127+
END $$;
101128

102129
-- Foreign keys: Material
103-
ALTER TABLE "Material"
104-
ADD CONSTRAINT "Material_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
105-
ALTER TABLE "Material"
106-
ADD CONSTRAINT "Material_sheetId_fkey" FOREIGN KEY ("sheetId") REFERENCES "StudySheet"("id") ON DELETE SET NULL ON UPDATE CASCADE;
107-
ALTER TABLE "Material"
108-
ADD CONSTRAINT "Material_noteId_fkey" FOREIGN KEY ("noteId") REFERENCES "Note"("id") ON DELETE SET NULL ON UPDATE CASCADE;
130+
DO $$
131+
BEGIN
132+
ALTER TABLE "Material"
133+
ADD CONSTRAINT "Material_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
134+
EXCEPTION
135+
WHEN duplicate_object THEN NULL;
136+
END $$;
137+
138+
DO $$
139+
BEGIN
140+
ALTER TABLE "Material"
141+
ADD CONSTRAINT "Material_sheetId_fkey" FOREIGN KEY ("sheetId") REFERENCES "StudySheet"("id") ON DELETE SET NULL ON UPDATE CASCADE;
142+
EXCEPTION
143+
WHEN duplicate_object THEN NULL;
144+
END $$;
145+
146+
DO $$
147+
BEGIN
148+
ALTER TABLE "Material"
149+
ADD CONSTRAINT "Material_noteId_fkey" FOREIGN KEY ("noteId") REFERENCES "Note"("id") ON DELETE SET NULL ON UPDATE CASCADE;
150+
EXCEPTION
151+
WHEN duplicate_object THEN NULL;
152+
END $$;
109153

110154
-- Foreign keys: MaterialAssignment
111-
ALTER TABLE "MaterialAssignment"
112-
ADD CONSTRAINT "MaterialAssignment_materialId_fkey" FOREIGN KEY ("materialId") REFERENCES "Material"("id") ON DELETE CASCADE ON UPDATE CASCADE;
113-
ALTER TABLE "MaterialAssignment"
114-
ADD CONSTRAINT "MaterialAssignment_sectionId_fkey" FOREIGN KEY ("sectionId") REFERENCES "Section"("id") ON DELETE CASCADE ON UPDATE CASCADE;
155+
DO $$
156+
BEGIN
157+
ALTER TABLE "MaterialAssignment"
158+
ADD CONSTRAINT "MaterialAssignment_materialId_fkey" FOREIGN KEY ("materialId") REFERENCES "Material"("id") ON DELETE CASCADE ON UPDATE CASCADE;
159+
EXCEPTION
160+
WHEN duplicate_object THEN NULL;
161+
END $$;
162+
163+
DO $$
164+
BEGIN
165+
ALTER TABLE "MaterialAssignment"
166+
ADD CONSTRAINT "MaterialAssignment_sectionId_fkey" FOREIGN KEY ("sectionId") REFERENCES "Section"("id") ON DELETE CASCADE ON UPDATE CASCADE;
167+
EXCEPTION
168+
WHEN duplicate_object THEN NULL;
169+
END $$;

backend/prisma/migrations/20260428000001_add_ai_suggestion/migration.sql

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,13 @@
1010
-- variant if we add a separate listing endpoint later.
1111
-- ON DELETE CASCADE keeps the table self-cleaning when a user is
1212
-- removed.
13+
--
14+
-- Every statement is guarded with IF NOT EXISTS so `prisma migrate
15+
-- deploy` is safe to retry on partial failure and is a no-op on an
16+
-- already-migrated DB (CLAUDE.md A5). The inline FK is created as part
17+
-- of the guarded CREATE TABLE, so it needs no separate guard.
1318

14-
CREATE TABLE "AiSuggestion" (
19+
CREATE TABLE IF NOT EXISTS "AiSuggestion" (
1520
"id" SERIAL PRIMARY KEY,
1621
"userId" INTEGER NOT NULL,
1722
"text" VARCHAR(280) NOT NULL,
@@ -24,8 +29,8 @@ CREATE TABLE "AiSuggestion" (
2429
ON DELETE CASCADE ON UPDATE CASCADE
2530
);
2631

27-
CREATE INDEX "AiSuggestion_userId_generatedAt_idx"
32+
CREATE INDEX IF NOT EXISTS "AiSuggestion_userId_generatedAt_idx"
2833
ON "AiSuggestion"("userId", "generatedAt");
2934

30-
CREATE INDEX "AiSuggestion_userId_dismissedAt_idx"
35+
CREATE INDEX IF NOT EXISTS "AiSuggestion_userId_dismissedAt_idx"
3136
ON "AiSuggestion"("userId", "dismissedAt");

0 commit comments

Comments
 (0)