diff --git a/.dockerignore b/.dockerignore index 343354e9..e9b6dc9c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -27,6 +27,9 @@ db.sqlite3 *.md !README.md +# Local infrastructure state +infra/.pulumi/ + # Test and development files .coverage htmlcov/ @@ -49,6 +52,10 @@ Thumbs.db # Development tools .clinerules/ .claude/ +node_modules/ +**/node_modules/ +.turbo/ +**/dist/ # Legacy documentation legacy_documentation/ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..997504b4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# SCM syntax highlighting & preventing 3-way merges +pixi.lock merge=binary linguist-language=YAML linguist-generated=true -diff diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..4dfa8627 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,56 @@ +name: test + +on: + pull_request: + push: + branches: [llteacher01] + +jobs: + web: + name: apps/web — typecheck, migrate, test + runs-on: ubuntu-latest + + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_USER: llteacher + POSTGRES_PASSWORD: dev + POSTGRES_DB: llteacher + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U llteacher -d llteacher" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + env: + DATABASE_URL: postgres://llteacher:dev@localhost:5432/llteacher + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Install psql + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends postgresql-client + + - name: Apply Postgres extensions + run: psql "$DATABASE_URL" -f apps/web/src/db/init/01_extensions.sql + + - name: Apply Drizzle migrations + working-directory: apps/web + run: npm run db:migrate + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm test diff --git a/.gitignore b/.gitignore index 121cbba3..46708d68 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,11 @@ db.sqlite3 db.sqlite3-journal media/ +# Project-specific +*.env +/.claude/ +/.agents/ + # Python *.py[cod] *$py.class @@ -115,3 +120,23 @@ cython_debug/ # Django specific staticfiles/ +# pixi environments +.pixi/* +!.pixi/config.toml + +# apps/web + apps/admin — un-ignore TS source lib directories the Python `lib/` pattern above matches by accident +!apps/web/src/lib/ +!apps/web/src/lib/** +!apps/admin/src/lib/ +!apps/admin/src/lib/** +!apps/admin/src/client/lib/ +!apps/admin/src/client/lib/** + +# Turborepo monorepo +node_modules/ +.turbo/ +.turbo-cache/ + +# Pulumi local state and generated environment files +infra/.pulumi/ +infra/.env diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..a45fd52c --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/Dockerfile.aws b/Dockerfile.aws new file mode 100644 index 00000000..a85857a5 --- /dev/null +++ b/Dockerfile.aws @@ -0,0 +1,35 @@ +FROM node:24-alpine AS build + +WORKDIR /app +COPY package.json package-lock.json turbo.json ./ +COPY apps/web/package.json apps/web/package.json +COPY apps/admin/package.json apps/admin/package.json +COPY packages/ui/package.json packages/ui/package.json +COPY infra/package.json infra/package.json +RUN npm ci + +COPY apps/web apps/web +COPY apps/admin apps/admin +COPY packages/ui packages/ui +RUN npm run build + +FROM node:24-alpine AS runtime + +ENV NODE_ENV=production \ + PORT=8080 +WORKDIR /app + +COPY package.json package-lock.json ./ +COPY apps/web/package.json apps/web/package.json +COPY apps/admin/package.json apps/admin/package.json +COPY packages/ui/package.json packages/ui/package.json +COPY infra/package.json infra/package.json +RUN npm ci --omit=dev --ignore-scripts --workspace=llteacher-web --include-workspace-root=false \ + && npm cache clean --force + +COPY --from=build /app/apps/web/dist /app/apps/web/dist +COPY --from=build /app/apps/admin/dist /app/apps/admin/dist + +USER node +EXPOSE 8080 +CMD ["node", "apps/web/dist/node/server.mjs"] diff --git a/README.md b/README.md index beb06f23..0370fee8 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,50 @@ # LLTeacher v2 +> **Turborepo monorepo.** TypeScript / React 19 / Vite / Tailwind 4 / Cloudflare Workers stack. +> Two workspaces today: `apps/web` (student-facing, port 2311) and `apps/admin` (instructor console, port 2312). +> Django legacy (`apps/accounts`, `apps/conversations`, `apps/homeworks`, `apps/llm`, plus `src/`, `services/`) +> remains the source of truth until cutover. +> See `docs/superpowers/plans/2026-06-01-llteacher-fullstack-port.md`. + AI-assisted educational platform for teachers and students. +## Monorepo layout + +``` +llteacher/ +├── package.json # root — npm workspaces + Turborepo task runner +├── turbo.json # task pipeline (build, dev, typecheck, test) +├── pyproject.toml # root — uv workspaces for Django apps +│ +├── apps/ +│ ├── web/ # TS workspace: student-facing app (port 2311) +│ ├── admin/ # TS workspace: instructor console (port 2312) +│ ├── accounts/ # Django legacy: user model + auth +│ ├── conversations/ # Django legacy: AI conversation + submission +│ ├── homeworks/ # Django legacy: homework + section CRUD +│ └── llm/ # Django legacy: LLM config + provider +│ +├── src/llteacher/ # Django project root +├── services/ # Django service layer (uv workspace) +└── docs/design-system/ # shared design system docs +``` + +The two manifest systems coexist in `apps/`: each TS workspace has a `package.json` (npm reads these), each Django workspace has a `pyproject.toml` (uv reads these). Neither tool sees the other's apps. + +### Common commands (from repo root) + +```bash +npm install # install all TS workspaces +npx turbo dev # boot all dev servers (web on 2311, admin on 2312) +npx turbo build # build all TS workspaces +npx turbo typecheck # tsc -b across all TS workspaces +npx turbo test # run all TS test suites +``` + +Or scope to one workspace: `npm run dev --workspace=llteacher-web`. + +The Django stack still uses its own commands (`uv run python run_tests.py`, `python manage.py runserver`) and is unaffected by Turborepo. + ## 🚀 Project Status **Phase 1 Complete**: Data Models & Testing Infrastructure ✅ diff --git a/apps/admin/.gitignore b/apps/admin/.gitignore new file mode 100644 index 00000000..5e1e2368 --- /dev/null +++ b/apps/admin/.gitignore @@ -0,0 +1,12 @@ +node_modules/ +dist/ +.env +.env.* +!.env.example +*.tsbuildinfo +*.config.d.ts +*.config.js +*.log +.DS_Store +coverage/ +.turbo/ diff --git a/apps/admin/README.md b/apps/admin/README.md new file mode 100644 index 00000000..541ee2e1 --- /dev/null +++ b/apps/admin/README.md @@ -0,0 +1,41 @@ +# llteacher-admin + +Instructor-facing console for LLteacher. Sibling workspace to `apps/web/` in the Turborepo. + +## Run + +From the repo root: + +```bash +npm install # installs workspaces +npx turbo dev # boots both web (2311) and admin (2312) +``` + +Or just this workspace: + +```bash +npm run dev --workspace=llteacher-admin # admin only on http://localhost:2312 +``` + +## Scope + +This is currently a **minimal scaffold**. It renders the shared UW Purple chrome (top nav + sidebar) with placeholder content. The instructor surfaces it will eventually own: + +- Course / homework / section authoring +- Student roster and submission review +- Conversation grading (replaces Django's submission detail view) +- LLM configuration + +## Shared design tokens — known duplication + +`src/client/styles.css` is currently a **copy** of `apps/web/src/client/styles.css`. The next monorepo task is extracting both copies into a `packages/ui` shared workspace so the design system lives in one place. Until then, any token changes need to be made in both files. + +## What admin does NOT have yet (vs. `apps/web/`) + +- No Cloudflare Worker — admin is a static SPA in this scaffold. A Hono Worker will be added when admin needs an API. +- No Drizzle / Neon — same reason. +- No WorkOS / AI SDK / Wrangler — added when needed. + +## Port + +`2312` — sits next to web's `2311`. Both ports use Vite's `strictPort: true` so they fail loudly if taken rather than silently picking another. diff --git a/apps/admin/package.json b/apps/admin/package.json new file mode 100644 index 00000000..c1c7b67f --- /dev/null +++ b/apps/admin/package.json @@ -0,0 +1,30 @@ +{ + "name": "llteacher-admin", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc -b", + "test": "vitest run --passWithNoTests", + "test:watch": "vitest" + }, + "dependencies": { + "@llteacher/ui": "*", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router": "^7.0.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vitest": "^2.1.0" + } +} diff --git a/apps/admin/src/client/App.tsx b/apps/admin/src/client/App.tsx new file mode 100644 index 00000000..fff29fd6 --- /dev/null +++ b/apps/admin/src/client/App.tsx @@ -0,0 +1,189 @@ +/* -------------------------------------------------------------------------- + LLteacher Admin — the instructor console. + + Shell composition: + [TopNav — UW Husky Purple, mirrors the student app's chrome with + an admin-mode indicator and a course-context breadcrumb] + [AdminSidebar — catalog navigation (Homeworks, Submissions, LLM + configs, Students) + quick actions] + [Main column — the current view, switched via type-safe tagged- + union state. Each view component is a self-contained page.] + + No router: the URL is the student app's domain, this is a bounded + admin surface, and the view space is small enough that tagged-union + state in a single useState beats a router dependency for now. + -------------------------------------------------------------------------- */ + +import { useEffect, useState } from "react"; +import { TopNav } from "@llteacher/ui"; +import { AdminSidebar } from "./components/AdminSidebar"; +import type { AdminNavKey } from "./components/AdminSidebar"; +import { HomeworksView } from "./views/HomeworksView"; +import { SubmissionsView } from "./views/SubmissionsView"; +import { LLMConfigsView } from "./views/LLMConfigsView"; +import { + HOMEWORKS, + LLM_CONFIGS, + SUBMISSIONS_HW_003, + CURRENT_TEACHER, +} from "./lib/fixtures"; + +/* localStorage key for the admin sidebar collapsed preference. Namespaced + separately from the student app — different surface, different user, + different optimal default. The "llteacher:" prefix avoids colliding + with any other app on the same origin. */ +const SIDEBAR_COLLAPSED_KEY = "llteacher:admin-sidebar-collapsed"; + +/* The view-state machine. Adding a view = adding a discriminated case. */ +type View = + | { kind: "homeworks" } + | { kind: "submissions"; homeworkId: string } + | { kind: "llm-configs" } + | { kind: "students" }; + +const NAV_BREADCRUMB: Record = { + "homeworks": "Instructor Console · Homeworks", + "submissions": "Instructor Console · Submissions", + "llm-configs": "Instructor Console · LLM Configs", + "students": "Instructor Console · Students", +}; + +export default function App() { + const [view, setView] = useState({ kind: "homeworks" }); + + /* Sidebar collapse persists across reloads via localStorage. Lazy + initializer reads on first render; the effect below writes on change. + The try/catch handles private mode where storage throws. */ + const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => { + if (typeof window === "undefined") return false; + try { + return window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "true"; + } catch { + return false; + } + }); + + useEffect(() => { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(isSidebarCollapsed)); + } catch { + /* private mode / quota — ignore */ + } + }, [isSidebarCollapsed]); + + const navKey: AdminNavKey = + view.kind === "submissions" ? "submissions" : (view.kind as AdminNavKey); + + const navigate = (key: AdminNavKey) => { + if (key === "submissions") { + /* No homework selected → default to the active homework */ + const active = HOMEWORKS.find((h) => h.status === "active") ?? HOMEWORKS[0]!; + setView({ kind: "submissions", homeworkId: active.id }); + } else { + setView({ kind: key } as View); + } + }; + + const initials = CURRENT_TEACHER.name + .split(" ") + .map((p) => p[0]) + .join("") + .slice(0, 2); + + return ( +
+ {/* Shared TopNav with admin mode. The Heritage Gold dot + "Admin" + marker in the affiliation tag is the at-a-glance "instructor + console" cue; the trailing breadcrumb segment names the view. */} + + +
+ setIsSidebarCollapsed((c) => !c)} + onNewHomework={() => { + /* TODO: route to HomeworkEditView when the form view lands. + For now the click is acknowledged with a console log so the + affordance feels live during the demo. */ + // eslint-disable-next-line no-console + console.log("[admin] new homework — form view not yet implemented"); + }} + onNewLLMConfig={() => { + // eslint-disable-next-line no-console + console.log("[admin] new LLM config — form view not yet implemented"); + }} + /> + +
+
+
+ {view.kind === "homeworks" && ( + setView({ kind: "submissions", homeworkId: id })} + onOpenSubmissions={(id) => setView({ kind: "submissions", homeworkId: id })} + onNewHomework={() => { + // eslint-disable-next-line no-console + console.log("[admin] new homework — form view not yet implemented"); + }} + /> + )} + + {view.kind === "submissions" && (() => { + const hw = HOMEWORKS.find((h) => h.id === view.homeworkId); + if (!hw) return ; + return ( + setView({ kind: "homeworks" })} + /> + ); + })()} + + {view.kind === "llm-configs" && ( + { + // eslint-disable-next-line no-console + console.log("[admin] open config", id); + }} + onNewConfig={() => { + // eslint-disable-next-line no-console + console.log("[admin] new LLM config"); + }} + /> + )} + + {view.kind === "students" && ( + + )} +
+
+
+
+
+ ); +} + +function EmptyView({ label }: { label: string }) { + return ( +
+ +

{label}

+

+ This view is scaffolded in the navigation but not yet implemented. + Wire it next — the data shape lives in lib/fixtures.ts. +

+
+ ); +} diff --git a/apps/admin/src/client/components/AdminSidebar.tsx b/apps/admin/src/client/components/AdminSidebar.tsx new file mode 100644 index 00000000..901b76e4 --- /dev/null +++ b/apps/admin/src/client/components/AdminSidebar.tsx @@ -0,0 +1,147 @@ +/* -------------------------------------------------------------------------- + AdminSidebar — instructor's left rail. + + Replaces the student syllabus rail with admin navigation: the catalog + sections (Homeworks, Submissions, LLM Configs, Students) plus quick + actions. Same UW Husky Purple surface as the student sidebar so the + brand reads as one product; different content so an instructor knows + they're in the console. + -------------------------------------------------------------------------- */ + +import { + BookOpen, + CaretDoubleLeft, + CaretDoubleRight, + ClipboardText, + Sparkle, + Users, + Plus, +} from "@phosphor-icons/react"; + +export type AdminNavKey = "homeworks" | "submissions" | "llm-configs" | "students"; + +export type AdminSidebarProps = { + active: AdminNavKey; + onNavigate: (key: AdminNavKey) => void; + onNewHomework: () => void; + onNewLLMConfig: () => void; + /** When true, the sidebar collapses to a 64px rail showing only icons. */ + isCollapsed?: boolean; + /** Called when the collapse toggle is clicked. */ + onToggleCollapse?: () => void; +}; + +type NavItem = { + key: AdminNavKey; + label: string; + icon: React.ReactNode; + description: string; +}; + +const NAV_ITEMS: NavItem[] = [ + { key: "homeworks", label: "Homeworks", icon: , description: "Course assignments" }, + { key: "submissions", label: "Submissions", icon: , description: "Student work" }, + { key: "llm-configs", label: "LLM configs", icon: , description: "Tutor models" }, + { key: "students", label: "Students", icon: , description: "Course roster" }, +]; + +export function AdminSidebar({ + active, + onNavigate, + onNewHomework, + onNewLLMConfig, + isCollapsed = false, + onToggleCollapse, +}: AdminSidebarProps) { + return ( + + ); +} diff --git a/apps/admin/src/client/components/PageHeader.tsx b/apps/admin/src/client/components/PageHeader.tsx new file mode 100644 index 00000000..f7f8d65f --- /dev/null +++ b/apps/admin/src/client/components/PageHeader.tsx @@ -0,0 +1,33 @@ +/* -------------------------------------------------------------------------- + PageHeader — the title strip at the top of every admin view. + + Top row: small Heritage Gold mono eyebrow (e.g., "HOMEWORKS · 5 + RECORDS"). Below: large Geist Sans title + optional secondary action + strip. The eyebrow + title pair is the consistent "you are here" + anchor across every admin view. + -------------------------------------------------------------------------- */ + +import type { ReactNode } from "react"; + +export type PageHeaderProps = { + /** Accepts a string or a ReactNode so the eyebrow can embed a RecordId */ + eyebrow: ReactNode; + title: string; + /** Optional supporting line beneath the title */ + subtitle?: string; + /** Actions rendered on the right of the title row (typically a button) */ + actions?: ReactNode; +}; + +export function PageHeader({ eyebrow, title, subtitle, actions }: PageHeaderProps) { + return ( +
+
{eyebrow}
+
+

{title}

+ {actions &&
{actions}
} +
+ {subtitle &&

{subtitle}

} +
+ ); +} diff --git a/apps/admin/src/client/components/RecordId.tsx b/apps/admin/src/client/components/RecordId.tsx new file mode 100644 index 00000000..11ddfa92 --- /dev/null +++ b/apps/admin/src/client/components/RecordId.tsx @@ -0,0 +1,32 @@ +/* -------------------------------------------------------------------------- + RecordId — the signature element of the admin console. + + Every cataloged artifact (homework, LLM config, student, etc.) renders + with a small Heritage Gold mono badge: `HW·003`, `CFG·001`. The midline + dot (· U+00B7) separates the prefix from the zero-padded index. Reads + as "we are in a catalog of typed records" — not a SaaS dashboard. + + The prefix encodes the record type; keep the prefix space short (2-4 + chars). The index is zero-padded to 3 digits for consistent width. + -------------------------------------------------------------------------- */ + +export type RecordIdProps = { + prefix: "HW" | "CFG" | "STU" | "SEC"; + index: number; + /** Visual size; defaults to "md" */ + size?: "sm" | "md"; +}; + +export function RecordId({ prefix, index, size = "md" }: RecordIdProps) { + const padded = String(index).padStart(3, "0"); + return ( + + {prefix} + + {padded} + + ); +} diff --git a/apps/admin/src/client/components/StatusBadge.tsx b/apps/admin/src/client/components/StatusBadge.tsx new file mode 100644 index 00000000..5185fe55 --- /dev/null +++ b/apps/admin/src/client/components/StatusBadge.tsx @@ -0,0 +1,35 @@ +/* -------------------------------------------------------------------------- + StatusBadge — mono small-caps badge for record state. + + Used across the admin to telegraph status: homework lifecycle, config + active/default flags, submission progress. Always renders in mono so + it visually pairs with the RecordId catalog badges. + -------------------------------------------------------------------------- */ + +export type StatusKind = + | "active" + | "draft" + | "scheduled" + | "archived" + | "past_due" + | "default" + | "inactive" + | "submitted" + | "in_progress" + | "missing" + | "no_interaction" + | "partial"; + +export type StatusBadgeProps = { + kind: StatusKind; + children: React.ReactNode; +}; + +export function StatusBadge({ kind, children }: StatusBadgeProps) { + return ( + + + ); +} diff --git a/apps/admin/src/client/index.html b/apps/admin/src/client/index.html new file mode 100644 index 00000000..eff4a555 --- /dev/null +++ b/apps/admin/src/client/index.html @@ -0,0 +1,12 @@ + + + + + + LLteacher Admin + + +
+ + + diff --git a/apps/admin/src/client/lib/fixtures.ts b/apps/admin/src/client/lib/fixtures.ts new file mode 100644 index 00000000..b40c90fe --- /dev/null +++ b/apps/admin/src/client/lib/fixtures.ts @@ -0,0 +1,274 @@ +/* -------------------------------------------------------------------------- + Fixture data for the admin console. Shapes mirror the Django models in + apps/{homeworks,llm,accounts,conversations}/src/models.py so the admin + UI can be wired to a real backend without changing component contracts. + + When the Drizzle schema lands (Phase 1), replace these fixtures with + real queries — the TypeScript types here are the contract. + -------------------------------------------------------------------------- */ + +/* -- Domain types ---------------------------------------------------------- */ + +export type Teacher = { + id: string; + name: string; + email: string; +}; + +export type Student = { + id: string; + name: string; + initials: string; + email: string; +}; + +export type LLMConfig = { + id: string; + /** Display index for the catalog ID badge — `CFG·001` */ + recordNumber: number; + name: string; + modelName: string; + /** First 100 chars of the system prompt for list display */ + basePromptPreview: string; + temperature: number; + maxCompletionTokens: number; + isDefault: boolean; + isActive: boolean; + createdAt: string; +}; + +export type SectionSummary = { + id: string; + homeworkId: string; + title: string; + order: number; + hasSolution: boolean; + /** How many students have submitted this section */ + submissionsCount: number; +}; + +export type Homework = { + id: string; + /** Display index for the catalog ID badge — `HW·003` */ + recordNumber: number; + title: string; + description: string; + dueDate: string; // ISO + llmConfigId: string | null; + sections: SectionSummary[]; + status: "draft" | "scheduled" | "active" | "past_due" | "archived"; + studentsTotal: number; + studentsActive: number; + submissionsCount: number; + lastActivity: string; +}; + +export type SectionProgressState = "missing" | "in_progress" | "submitted"; + +export type SubmissionRow = { + studentId: string; + studentName: string; + studentInitials: string; + /** Per-section progress, indexed by section.order */ + sectionsProgress: { sectionNumber: number; state: SectionProgressState }[]; + conversationCount: number; + status: "no_interaction" | "partial" | "active"; + /** Display-formatted relative time, e.g. "2h ago" or "—" */ + lastActivity: string | null; +}; + +/* -- Fixture data ---------------------------------------------------------- */ + +export const CURRENT_TEACHER: Teacher = { + id: "teacher-001", + name: "Anjali Chen", + email: "achen@uw.edu", +}; + +export const LLM_CONFIGS: LLMConfig[] = [ + { + id: "llm-001", + recordNumber: 1, + name: "Socratic Default", + modelName: "gpt-4o-mini", + basePromptPreview: + "You are an AI tutor helping students learn. Your role is to guide students through problems without giving away the complete answer…", + temperature: 0.7, + maxCompletionTokens: 1000, + isDefault: true, + isActive: true, + createdAt: "2026-03-15", + }, + { + id: "llm-002", + recordNumber: 2, + name: "Gemma Free Tier", + modelName: "google/gemma-4-31b-it:free", + basePromptPreview: + "You are an AI tutor for an introductory statistics course at the University of Washington. Guide students using the Socratic method…", + temperature: 0.6, + maxCompletionTokens: 1200, + isDefault: false, + isActive: true, + createdAt: "2026-05-28", + }, + { + id: "llm-003", + recordNumber: 3, + name: "Conceptual Only — no R", + modelName: "gpt-4o-mini", + basePromptPreview: + "You are a statistics tutor. Focus on conceptual reasoning. Do not generate R code under any circumstances — refer students to office hours…", + temperature: 0.4, + maxCompletionTokens: 800, + isDefault: false, + isActive: false, + createdAt: "2026-04-02", + }, +]; + +const SECTION_FIXTURES_BY_HW: Record = { + "hw-001": [ + { id: "s-101", homeworkId: "hw-001", title: "Sample spaces", order: 1, hasSolution: true, submissionsCount: 31 }, + { id: "s-102", homeworkId: "hw-001", title: "Events and probability", order: 2, hasSolution: true, submissionsCount: 29 }, + { id: "s-103", homeworkId: "hw-001", title: "Conditional probability", order: 3, hasSolution: true, submissionsCount: 28 }, + ], + "hw-002": [ + { id: "s-201", homeworkId: "hw-002", title: "Discrete random variables", order: 1, hasSolution: true, submissionsCount: 26 }, + { id: "s-202", homeworkId: "hw-002", title: "Continuous random variables", order: 2, hasSolution: true, submissionsCount: 25 }, + { id: "s-203", homeworkId: "hw-002", title: "Expectation and variance", order: 3, hasSolution: false, submissionsCount: 24 }, + { id: "s-204", homeworkId: "hw-002", title: "Joint distributions", order: 4, hasSolution: true, submissionsCount: 22 }, + ], + "hw-003": [ + { id: "s-301", homeworkId: "hw-003", title: "Random variables", order: 1, hasSolution: true, submissionsCount: 18 }, + { id: "s-302", homeworkId: "hw-003", title: "Probability distributions", order: 2, hasSolution: true, submissionsCount: 14 }, + { id: "s-303", homeworkId: "hw-003", title: "P-values", order: 3, hasSolution: true, submissionsCount: 6 }, + { id: "s-304", homeworkId: "hw-003", title: "Confidence intervals", order: 4, hasSolution: false, submissionsCount: 0 }, + { id: "s-305", homeworkId: "hw-003", title: "Hypothesis testing", order: 5, hasSolution: false, submissionsCount: 0 }, + ], + "hw-004": [ + { id: "s-401", homeworkId: "hw-004", title: "Sampling distributions", order: 1, hasSolution: false, submissionsCount: 0 }, + { id: "s-402", homeworkId: "hw-004", title: "Central limit theorem", order: 2, hasSolution: false, submissionsCount: 0 }, + { id: "s-403", homeworkId: "hw-004", title: "Standard error", order: 3, hasSolution: false, submissionsCount: 0 }, + ], + "hw-005": [ + { id: "s-501", homeworkId: "hw-005", title: "Two-sample tests", order: 1, hasSolution: false, submissionsCount: 0 }, + { id: "s-502", homeworkId: "hw-005", title: "Chi-squared tests", order: 2, hasSolution: false, submissionsCount: 0 }, + ], +}; + +export const HOMEWORKS: Homework[] = [ + { + id: "hw-001", + recordNumber: 1, + title: "Probability foundations", + description: "Sample spaces, events, conditional probability, and Bayes' rule with worked examples.", + dueDate: "2026-04-12", + llmConfigId: "llm-001", + sections: SECTION_FIXTURES_BY_HW["hw-001"]!, + status: "archived", + studentsTotal: 32, + studentsActive: 31, + submissionsCount: 88, + lastActivity: "2026-04-12", + }, + { + id: "hw-002", + recordNumber: 2, + title: "Random variables and distributions", + description: "Discrete vs. continuous random variables, expectation, variance, and joint distributions.", + dueDate: "2026-05-10", + llmConfigId: "llm-001", + sections: SECTION_FIXTURES_BY_HW["hw-002"]!, + status: "archived", + studentsTotal: 32, + studentsActive: 30, + submissionsCount: 97, + lastActivity: "2026-05-10", + }, + { + id: "hw-003", + recordNumber: 3, + title: "Probability and Distributions", + description: "Random variables, probability distributions, p-values, confidence intervals, and hypothesis testing.", + dueDate: "2026-06-09", + llmConfigId: "llm-002", + sections: SECTION_FIXTURES_BY_HW["hw-003"]!, + status: "active", + studentsTotal: 32, + studentsActive: 24, + submissionsCount: 38, + lastActivity: "1h ago", + }, + { + id: "hw-004", + recordNumber: 4, + title: "Sampling and the CLT", + description: "Sampling distributions, the central limit theorem, and standard error.", + dueDate: "2026-06-23", + llmConfigId: null, + sections: SECTION_FIXTURES_BY_HW["hw-004"]!, + status: "draft", + studentsTotal: 32, + studentsActive: 0, + submissionsCount: 0, + lastActivity: "—", + }, + { + id: "hw-005", + recordNumber: 5, + title: "Two-sample inference", + description: "Two-sample t-tests, chi-squared tests, and effect size reporting.", + dueDate: "2026-07-07", + llmConfigId: null, + sections: SECTION_FIXTURES_BY_HW["hw-005"]!, + status: "scheduled", + studentsTotal: 32, + studentsActive: 0, + submissionsCount: 0, + lastActivity: "—", + }, +]; + +/* Per-student submission rows for HW 3 (the active homework) */ +export const SUBMISSIONS_HW_003: SubmissionRow[] = [ + mkRow("stu-001", "Aiden Park", "AP", [1,1,1,0,0], 7, "active", "12m ago"), + mkRow("stu-002", "Bea Sandoval", "BS", [1,1,1,0,0], 6, "active", "34m ago"), + mkRow("stu-003", "Carlos Mendoza", "CM", [1,1,2,0,0], 8, "active", "1h ago"), + mkRow("stu-004", "Devi Krishnan", "DK", [1,1,1,0,0], 5, "active", "2h ago"), + mkRow("stu-005", "Elena Vasquez", "EV", [1,1,2,0,0], 9, "active", "3h ago"), + mkRow("stu-006", "Fatima Al-Khouri", "FA", [1,1,2,0,0], 6, "active", "5h ago"), + mkRow("stu-007", "Gabriel Tanaka", "GT", [1,2,0,0,0], 3, "partial", "8h ago"), + mkRow("stu-008", "Hana Liu", "HL", [1,1,2,0,0], 4, "active", "1d ago"), + mkRow("stu-009", "Idris Mohamed", "IM", [1,2,0,0,0], 2, "partial", "2d ago"), + mkRow("stu-010", "Jade Okafor", "JO", [1,1,0,0,0], 3, "active", "2d ago"), + mkRow("stu-011", "Kai Yoshida", "KY", [1,0,0,0,0], 1, "partial", "3d ago"), + mkRow("stu-012", "Lucia Romano", "LR", [0,0,0,0,0], 0, "no_interaction", null), + mkRow("stu-013", "Mateo Rivera", "MR", [0,0,0,0,0], 0, "no_interaction", null), + mkRow("stu-014", "Nia Adeyemi", "NA", [0,0,0,0,0], 0, "no_interaction", null), +]; + +function mkRow( + id: string, + name: string, + initials: string, + progressCodes: number[], // 0=missing, 1=submitted, 2=in_progress + conversationCount: number, + status: SubmissionRow["status"], + lastActivity: string | null, +): SubmissionRow { + const codeToState = (n: number): SectionProgressState => + n === 1 ? "submitted" : n === 2 ? "in_progress" : "missing"; + return { + studentId: id, + studentName: name, + studentInitials: initials, + sectionsProgress: progressCodes.map((c, i) => ({ + sectionNumber: i + 1, + state: codeToState(c), + })), + conversationCount, + status, + lastActivity, + }; +} diff --git a/apps/admin/src/client/main.tsx b/apps/admin/src/client/main.tsx new file mode 100644 index 00000000..9904207f --- /dev/null +++ b/apps/admin/src/client/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "@llteacher/ui/styles.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/apps/admin/src/client/views/HomeworksView.tsx b/apps/admin/src/client/views/HomeworksView.tsx new file mode 100644 index 00000000..788d73c7 --- /dev/null +++ b/apps/admin/src/client/views/HomeworksView.tsx @@ -0,0 +1,148 @@ +/* -------------------------------------------------------------------------- + HomeworksView — the admin's primary landing view. + + A catalog of homework records: each row anchored by its `HW·xxx` ID + badge, with title, due date, section count, status, and a fast path + into per-homework submissions. Records appear with a brief staggered + entrance on first paint — subtle but enough to telegraph "this is a + live catalog, not static documentation." + -------------------------------------------------------------------------- */ + +import { ArrowRight, CalendarBlank, ClipboardText, Folder } from "@phosphor-icons/react"; +import { PageHeader } from "../components/PageHeader"; +import { RecordId } from "../components/RecordId"; +import { StatusBadge } from "../components/StatusBadge"; +import type { Homework } from "../lib/fixtures"; + +export type HomeworksViewProps = { + homeworks: Homework[]; + onOpenHomework: (id: string) => void; + onOpenSubmissions: (id: string) => void; + onNewHomework: () => void; +}; + +const STATUS_LABEL: Record = { + active: "active", + draft: "draft", + scheduled: "scheduled", + archived: "archived", + past_due: "past due", +}; + +function formatDueDate(iso: string): string { + const d = new Date(iso + "T00:00:00"); + return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); +} + +export function HomeworksView({ + homeworks, + onOpenHomework, + onOpenSubmissions, + onNewHomework, +}: HomeworksViewProps) { + const totalStudents = homeworks[0]?.studentsTotal ?? 0; + const totalSubmissions = homeworks.reduce((s, h) => s + h.submissionsCount, 0); + const activeCount = homeworks.filter((h) => h.status === "active").length; + + return ( +
+ + + New homework + + } + /> + +
+
+
Records
+
{homeworks.length}
+
+
+
Active
+
{activeCount}
+
+
+
Students enrolled
+
{totalStudents}
+
+
+
Submissions logged
+
{totalSubmissions}
+
+
+ +
+ {homeworks.map((hw, idx) => ( +
+
+ +
+ +
+ +
+ + + + + + +
+

{hw.description}

+
+ +
+ + {STATUS_LABEL[hw.status]} + +
+ +
+ + +
+
+ ))} +
+
+ ); +} diff --git a/apps/admin/src/client/views/LLMConfigsView.tsx b/apps/admin/src/client/views/LLMConfigsView.tsx new file mode 100644 index 00000000..597949dc --- /dev/null +++ b/apps/admin/src/client/views/LLMConfigsView.tsx @@ -0,0 +1,130 @@ +/* -------------------------------------------------------------------------- + LLMConfigsView — the catalog of LLM tutor configurations. + + Each row is a CFG·xxx record showing model, temperature, token cap, + and a one-line prompt preview. The default config is anchored with a + small Heritage Gold "★ default" marker rather than a chip-shaped + badge so the gold-accent system stays consistent (gold == AI-side + authority across both apps). + -------------------------------------------------------------------------- */ + +import { Plus, Sparkle, Thermometer } from "@phosphor-icons/react"; +import { PageHeader } from "../components/PageHeader"; +import { RecordId } from "../components/RecordId"; +import { StatusBadge } from "../components/StatusBadge"; +import type { LLMConfig } from "../lib/fixtures"; + +export type LLMConfigsViewProps = { + configs: LLMConfig[]; + onOpenConfig: (id: string) => void; + onNewConfig: () => void; +}; + +export function LLMConfigsView({ + configs, + onOpenConfig, + onNewConfig, +}: LLMConfigsViewProps) { + const activeCount = configs.filter((c) => c.isActive).length; + const defaultConfig = configs.find((c) => c.isDefault); + + return ( +
+ +
+ ); +} diff --git a/apps/admin/src/client/views/SubmissionsView.tsx b/apps/admin/src/client/views/SubmissionsView.tsx new file mode 100644 index 00000000..e91fa2c9 --- /dev/null +++ b/apps/admin/src/client/views/SubmissionsView.tsx @@ -0,0 +1,188 @@ +/* -------------------------------------------------------------------------- + SubmissionsView — student-by-student submission dashboard for a single + homework. + + The most distinctive admin view: a roster of students with per-section + progress visualized as a row of small markers. Three quick-filter + chips at the top (All / Active / No interaction) act on a single + data set with no remote round-trip. + -------------------------------------------------------------------------- */ + +import { useMemo, useState } from "react"; +import { ArrowLeft, ChatCircleDots, ClipboardText, Warning } from "@phosphor-icons/react"; +import { PageHeader } from "../components/PageHeader"; +import { RecordId } from "../components/RecordId"; +import { StatusBadge } from "../components/StatusBadge"; +import type { Homework, SubmissionRow } from "../lib/fixtures"; + +export type SubmissionsViewProps = { + homework: Homework; + rows: SubmissionRow[]; + onBack: () => void; +}; + +type Filter = "all" | "active" | "no_interaction"; + +const STATUS_LABEL: Record = { + active: "active", + partial: "partial", + no_interaction: "no interaction", +}; + +export function SubmissionsView({ homework, rows, onBack }: SubmissionsViewProps) { + const [filter, setFilter] = useState("all"); + + const counts = useMemo(() => ({ + total: rows.length, + active: rows.filter((r) => r.status === "active").length, + partial: rows.filter((r) => r.status === "partial").length, + no_interaction: rows.filter((r) => r.status === "no_interaction").length, + }), [rows]); + + const filtered = useMemo(() => { + if (filter === "all") return rows; + if (filter === "active") return rows.filter((r) => r.status === "active"); + return rows.filter((r) => r.status === "no_interaction"); + }, [rows, filter]); + + return ( +
+ + + + + + SUBMISSIONS + + } + title={homework.title} + subtitle={`${homework.sections.length} sections · due ${new Date(homework.dueDate + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`} + /> + + {counts.no_interaction > 0 && ( +
+ + + {counts.no_interaction}{" "} + {counts.no_interaction === 1 ? "student has" : "students have"} not started this homework. + +
+ )} + +
+
+
Total students
+
{counts.total}
+
+
+
Active
+
{counts.active}
+
+
+
Partial
+
{counts.partial}
+
+
+
No interaction
+
{counts.no_interaction}
+
+
+ +
+ setFilter("all")} label="All" count={counts.total} /> + setFilter("active")} label="Active" count={counts.active} /> + setFilter("no_interaction")} label="No interaction" count={counts.no_interaction} /> +
+ +
+ + + {filtered.map((row, idx) => ( +
+
+ +
+
+ {row.studentName} + {row.studentId} +
+
+ {row.sectionsProgress.map((sp) => ( + + {sp.sectionNumber} + + ))} +
+
+
+
+ + {STATUS_LABEL[row.status]} + +
+
+ {row.lastActivity ?? } +
+
+ ))} + + {filtered.length === 0 && ( +
+
+ )} +
+
+ ); +} + +function FilterChip({ + active, + onClick, + label, + count, +}: { + active: boolean; + onClick: () => void; + label: string; + count: number; +}) { + return ( + + ); +} diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json new file mode 100644 index 00000000..704ebb04 --- /dev/null +++ b/apps/admin/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "types": ["vite/client"] + }, + "include": ["src/client"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/apps/admin/tsconfig.node.json b/apps/admin/tsconfig.node.json new file mode 100644 index 00000000..97ede7ee --- /dev/null +++ b/apps/admin/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts new file mode 100644 index 00000000..6aebf56a --- /dev/null +++ b/apps/admin/vite.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; + +export default defineConfig({ + base: "/instructor/", + root: "src/client", + server: { + port: 2312, + strictPort: true, + }, + preview: { + port: 2312, + strictPort: true, + }, + build: { + outDir: "../../dist/client", + emptyOutDir: true, + }, + plugins: [react(), tailwindcss()], +}); diff --git a/apps/web/.dev.vars.example b/apps/web/.dev.vars.example new file mode 100644 index 00000000..2be3c739 --- /dev/null +++ b/apps/web/.dev.vars.example @@ -0,0 +1,4 @@ +DATABASE_URL="postgresql://user:password@host.neon.tech/llteacher?sslmode=require" +WORKOS_API_KEY="sk_test_placeholder" +WORKOS_CLIENT_ID="client_placeholder" +OPENROUTER_API_KEY="sk-or-v1-placeholder" diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 00000000..a669d2b7 --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,14 @@ +node_modules/ +dist/ +.dev.vars +.wrangler/ +.vite/ +.env +.env.* +!.env.example +*.tsbuildinfo +*.config.d.ts +*.config.js +*.log +.DS_Store +coverage/ diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 00000000..7d187a27 --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,14 @@ +# llteacher-web + +TypeScript / React 19 / Vite / Tailwind 4 / Hono / Cloudflare Workers / Drizzle / Neon port of LLteacher. + +## Setup + +1. `npm install` +2. Copy `.dev.vars.example` to `.dev.vars` and fill in real values. +3. `npm run db:migrate` (after Drizzle schema exists in Phase 1). +4. `npm run dev` to start Vite + Wrangler in dev mode. + +## Phase 0 status + +Scaffolding only. Auth, LLM, real routes land in subsequent phases (see `../docs/superpowers/plans/`). diff --git a/apps/web/drizzle.config.ts b/apps/web/drizzle.config.ts new file mode 100644 index 00000000..98aa55cf --- /dev/null +++ b/apps/web/drizzle.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + schema: "./src/db/schema.ts", + out: "./src/db/migrations", + dialect: "postgresql", + dbCredentials: { + url: process.env.DATABASE_URL!, + }, + verbose: true, + strict: true, +}); diff --git a/apps/web/node/entry.ts b/apps/web/node/entry.ts new file mode 100644 index 00000000..d64b98d9 --- /dev/null +++ b/apps/web/node/entry.ts @@ -0,0 +1,83 @@ +import { serve } from "@hono/node-server"; +import { createReadStream } from "node:fs"; +import { access, stat } from "node:fs/promises"; +import { extname, isAbsolute, join, normalize, relative, resolve } from "node:path"; +import { Readable } from "node:stream"; +import app from "../src/server/index"; + +const port = Number.parseInt(process.env.PORT ?? "8080", 10); +const webRoot = resolve(process.env.WEB_ASSETS_DIR ?? "apps/web/dist/client"); +const adminRoot = resolve(process.env.ADMIN_ASSETS_DIR ?? "apps/admin/dist/client"); + +const contentTypes: Record = { + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".ico": "image/x-icon", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".svg": "image/svg+xml", + ".woff": "font/woff", + ".woff2": "font/woff2", +}; + +async function existingFile(root: string, requestedPath: string): Promise { + const safePath = normalize(requestedPath).replace(/^(\.\.(\/|\\|$))+/, ""); + const candidate = resolve(root, safePath); + const relativeCandidate = relative(root, candidate); + if (relativeCandidate.startsWith("..") || isAbsolute(relativeCandidate)) return undefined; + try { + await access(candidate); + return (await stat(candidate)).isFile() ? candidate : undefined; + } catch { + return undefined; + } +} + +async function staticResponse(request: Request): Promise { + const pathname = new URL(request.url).pathname; + const isAdmin = pathname === "/instructor" || pathname.startsWith("/instructor/"); + const root = isAdmin ? adminRoot : webRoot; + const relativePath = isAdmin + ? pathname.replace(/^\/instructor\/?/, "") + : pathname.replace(/^\//, ""); + const requestedFile = relativePath ? await existingFile(root, relativePath) : undefined; + const file = requestedFile ?? join(root, "index.html"); + + try { + await access(file); + const nodeStream = createReadStream(file); + return new Response(Readable.toWeb(nodeStream) as ReadableStream, { + headers: { + "content-type": contentTypes[extname(file)] ?? "application/octet-stream", + "x-content-type-options": "nosniff", + }, + }); + } catch { + return new Response("Not Found", { status: 404 }); + } +} + +const assets = { fetch: staticResponse }; + +serve( + { + port, + fetch: (request) => { + const pathname = new URL(request.url).pathname; + if (pathname === "/health") { + return Response.json({ status: "ok" }); + } + return app.fetch(request, { + DATABASE_URL: process.env.DATABASE_URL ?? "", + WORKOS_API_KEY: process.env.WORKOS_API_KEY ?? "", + WORKOS_CLIENT_ID: process.env.WORKOS_CLIENT_ID ?? "", + OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY ?? "", + ASSETS: assets, + } as Env); + }, + }, + ({ port: listeningPort }) => { + console.log(`LLTeacher AWS server listening on port ${listeningPort}`); + }, +); diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 00000000..0ba514f9 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,50 @@ +{ + "name": "llteacher-web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build && npm run build:node", + "build:node": "esbuild node/entry.ts --bundle --platform=node --format=esm --outfile=dist/node/server.mjs --packages=external", + "start:node": "node dist/node/server.mjs", + "preview": "vite preview", + "deploy": "wrangler deploy", + "typecheck": "tsc -b", + "test": "vitest run", + "test:watch": "vitest", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:studio": "drizzle-kit studio" + }, + "dependencies": { + "@hono/node-server": "^1.19.0", + "@ai-sdk/openai": "^2.0.106", + "@ai-sdk/react": "^2.0.197", + "@llteacher/ui": "*", + "@neondatabase/serverless": "^0.10.0", + "@workos-inc/node": "^7.0.0", + "ai": "^5.0.0", + "drizzle-orm": "^0.36.0", + "hono": "^4.6.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router": "^7.0.0", + "zod": "^3.25.76" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "^1.0.0", + "@cloudflare/workers-types": "^4.20250101.0", + "@tailwindcss/vite": "^4.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.0", + "drizzle-kit": "^0.28.0", + "esbuild": "^0.25.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vitest": "^2.1.0", + "wrangler": "^3.95.0" + } +} diff --git a/apps/web/src/client/App.tsx b/apps/web/src/client/App.tsx new file mode 100644 index 00000000..c57a0a32 --- /dev/null +++ b/apps/web/src/client/App.tsx @@ -0,0 +1,228 @@ +import { useEffect, useState } from "react"; +import { useChat } from "@ai-sdk/react"; +import { DefaultChatTransport } from "ai"; +import { Sidebar, TopNav, ConversationView, renderToolPart } from "@llteacher/ui"; +import type { SidebarSection, MessageData, ToolPart } from "@llteacher/ui"; + +/* ========================================================================== + LLTeacher v2 — Chat-with-syllabus shell + Section 3 P-Values — STATS 311, Homework 3 + + Three-zone vertical shell: + [TOP NAV — UW Husky Purple, full-bleed 56px] + [Sidebar 240px UW Husky Purple] [Conversation max 720px paper] + + The top nav carries branding, course/term/homework context, and the user + account menu. The sidebar carries the homework syllabus (section progress) + for the current homework only — not generic thread history. + + Purple is the chrome. Heritage Gold is the AI's voice. Paper is the content. + ========================================================================== */ + +/* -- Worker status ping ---------------------------------------------------- */ + +type HelloResponse = { message: string; ping_id: string }; + +function useWorkerStatus() { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch("/api/hello") + .then((r) => { + if (!r.ok) throw new Error(`status ${r.status}`); + return r.json() as Promise; + }) + .then((data) => { + setStatus(data.ping_id.slice(0, 8)); + setLoading(false); + }) + .catch(() => { + setStatus(null); + setLoading(false); + }); + }, []); + + return { status, loading }; +} + +/* -- Homework sections fixture data ---------------------------------------- */ + +/** localStorage key for the sidebar collapsed preference. Namespaced so it + doesn't collide with future preference keys (`llteacher:*`). */ +const SIDEBAR_COLLAPSED_KEY = "llteacher:sidebar-collapsed"; + +const INITIAL_SECTIONS: SidebarSection[] = [ + { number: 1, title: "Random variables", status: "submitted" }, + { number: 2, title: "Probability distributions", status: "submitted" }, + { number: 3, title: "P-values", status: "current" }, + { number: 4, title: "Confidence intervals", status: "pending" }, + { number: 5, title: "Hypothesis testing", status: "pending" }, +]; + +/* ========================================================================== + App — the root component + + Chat state is owned by the AI SDK's useChat hook. We translate the + UIMessage[] it manages into the design system's MessageData[] for + ConversationView. Empty initial state — the student starts by typing. + ========================================================================== */ + +export default function App() { + const { status: workerStatus, loading: workerLoading } = useWorkerStatus(); + + /* The AI SDK chat — owns messages + streaming state. */ + const { + messages: aiMessages, + sendMessage, + status: chatStatus, + } = useChat({ + transport: new DefaultChatTransport({ api: "/api/chat" }), + }); + + const [sections, setSections] = useState(INITIAL_SECTIONS); + const [currentSection, setCurrentSection] = useState(3); + const [hintCount, setHintCount] = useState(3); + const [justSubmittedSection, setJustSubmittedSection] = useState(null); + /* Sidebar collapse persists across reloads via localStorage. Lazy initializer + reads on first render; the effect below writes whenever the state changes. */ + const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => { + if (typeof window === "undefined") return false; + try { + return window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "true"; + } catch { + /* Private mode / disabled storage — fall back to default expanded state */ + return false; + } + }); + + useEffect(() => { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(isSidebarCollapsed)); + } catch { + /* localStorage may throw (private mode quota, etc.) — silently ignore */ + } + }, [isSidebarCollapsed]); + + /* Translate AI SDK UIMessages into the design system's MessageData. AI + messages get a ReactNode body assembled from their `parts` — text parts + become paragraphs, tool-* parts go through the renderToolPart registry + in @llteacher/ui/generative. Streaming state applies only to the last + AI message. */ + const messages: MessageData[] = aiMessages.map((m, idx) => { + const isLast = idx === aiMessages.length - 1; + const isStreaming = isLast && chatStatus === "streaming"; + + if (m.role === "assistant") { + const content = ( + <> + {m.parts.map((part, i) => { + if (part.type === "text") { + return

{part.text}

; + } + return renderToolPart(part as ToolPart, `tool-${m.id}-${i}`); + })} + + ); + return { + id: m.id, + role: "ai" as const, + content, + isStreaming, + }; + } + + if (m.role === "user") { + const text = m.parts + .filter((p): p is { type: "text"; text: string } => p.type === "text") + .map((p) => p.text) + .join(""); + return { + id: m.id, + role: "student" as const, + content: text, + }; + } + + /* system role messages — not user-facing in this UI; render empty */ + return { + id: m.id, + role: "system" as const, + content: "", + }; + }); + + /* While the request is in flight but no tokens have streamed yet, the AI + SDK has no assistant message in `aiMessages` -- so the streaming dots + have nothing to attach to. Append a synthetic placeholder so the user + sees the AI is thinking; it drops out the moment the first real part + arrives and chatStatus transitions to "streaming". */ + if (chatStatus === "submitted") { + messages.push({ + id: "__pending__", + role: "ai" as const, + content: null, + isStreaming: true, + }); + } + + const handleSendMessage = (text: string) => { + sendMessage({ text }); + /* Each AI response counts as a hint — increments trigger the gold flash + on the sidebar's hint-history-row count numeral. */ + setHintCount((n) => n + 1); + }; + + const handleSubmit = (sectionNumber: number) => { + /* Transition the section to submitted and trigger the gold-halo + success animation on its ✓ indicator. The flag clears after the + animation duration (~700ms) so the indicator settles into its + normal submitted state. */ + setSections((prev) => + prev.map((s) => + s.number === sectionNumber ? { ...s, status: "submitted" as const } : s, + ), + ); + setJustSubmittedSection(sectionNumber); + setTimeout(() => setJustSubmittedSection(null), 800); + }; + + return ( +
+ {/* Top nav — UW Husky Purple full-bleed bar */} + + + {/* Sidebar + main row */} +
+ {/* Left rail — homework section progress on UW Husky Purple */} + setIsSidebarCollapsed((c) => !c)} + onSectionSelect={setCurrentSection} + onSubmit={handleSubmit} + workerStatus={workerStatus} + workerLoading={workerLoading} + /> + + {/* Main conversation column — warm paper surface */} + +
+
+ ); +} diff --git a/apps/web/src/client/index.html b/apps/web/src/client/index.html new file mode 100644 index 00000000..fbc521ec --- /dev/null +++ b/apps/web/src/client/index.html @@ -0,0 +1,12 @@ + + + + + + LLteacher + + +
+ + + diff --git a/apps/web/src/client/main.tsx b/apps/web/src/client/main.tsx new file mode 100644 index 00000000..9904207f --- /dev/null +++ b/apps/web/src/client/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "@llteacher/ui/styles.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/apps/web/src/db/client.ts b/apps/web/src/db/client.ts new file mode 100644 index 00000000..f6784c85 --- /dev/null +++ b/apps/web/src/db/client.ts @@ -0,0 +1,10 @@ +import { drizzle } from "drizzle-orm/neon-http"; +import { neon } from "@neondatabase/serverless"; +import * as schema from "./schema"; + +export function makeDb(databaseUrl: string) { + const sql = neon(databaseUrl); + return drizzle(sql, { schema }); +} + +export type Db = ReturnType; diff --git a/apps/web/src/db/init/01_extensions.sql b/apps/web/src/db/init/01_extensions.sql new file mode 100644 index 00000000..80abf378 --- /dev/null +++ b/apps/web/src/db/init/01_extensions.sql @@ -0,0 +1,14 @@ +-- Postgres extensions required by the LLteacher schema. +-- +-- Canonical manifest of "what extensions does this schema need." Apply +-- once against whichever Postgres instance the migrations will target, +-- before running drizzle migrations: +-- +-- psql "$DATABASE_URL" -f apps/web/src/db/init/01_extensions.sql +-- +-- Targets: +-- - Personal Neon dev branch (free tier) -- local development +-- - GitHub Actions Postgres service -- CI (.github/workflows/test.yml) +-- - UW-issued Neon project -- staging / prod (when access lands) + +CREATE EXTENSION IF NOT EXISTS vector; diff --git a/apps/web/src/db/migrations/0000_majestic_mentallo.sql b/apps/web/src/db/migrations/0000_majestic_mentallo.sql new file mode 100644 index 00000000..4f10faa1 --- /dev/null +++ b/apps/web/src/db/migrations/0000_majestic_mentallo.sql @@ -0,0 +1,161 @@ +CREATE TYPE "public"."course_role" AS ENUM('instructor', 'ta', 'student', 'observer', 'admin');--> statement-breakpoint +CREATE TYPE "public"."credential_provider" AS ENUM('openai', 'anthropic', 'claude_for_education', 'openrouter', 'local', 'canvas', 'workos');--> statement-breakpoint +CREATE TYPE "public"."lms_provider" AS ENUM('canvas');--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "pings" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "message" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "course_memberships" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "course_id" uuid NOT NULL, + "role" "course_role" NOT NULL, + "canvas_enrollment_id" text, + "canvas_role" text, + "enrolled_at" timestamp with time zone DEFAULT now() NOT NULL, + "dropped_at" timestamp with time zone, + "last_synced_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "courses" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "canvas_course_id" text, + "code" text NOT NULL, + "term" text NOT NULL, + "title" text NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "last_synced_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "lms_integrations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "course_id" uuid NOT NULL, + "provider" "lms_provider" DEFAULT 'canvas' NOT NULL, + "lti_iss" text NOT NULL, + "lti_client_id" text NOT NULL, + "lti_deployment_id" text NOT NULL, + "api_credential_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "lti_launches" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "lms_integration_id" uuid NOT NULL, + "user_id" uuid, + "nonce" text NOT NULL, + "resource_link_id" text, + "role_claim" text, + "occurred_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "organization_credentials" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "provider" "credential_provider" NOT NULL, + "label" text NOT NULL, + "secret_ref" text NOT NULL, + "rotated_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "organizations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "slug" text NOT NULL, + "name" text NOT NULL, + "workos_organization_id" text NOT NULL, + "canvas_account_id" text, + "requires_ferpa" boolean DEFAULT true NOT NULL, + "requires_hipaa" boolean DEFAULT false NOT NULL, + "data_residency" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "users" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workos_user_id" text, + "email" "bytea" NOT NULL, + "email_blind_index" "bytea" NOT NULL, + "netid" "bytea", + "netid_blind_index" "bytea", + "display_name" "bytea", + "is_pending" boolean DEFAULT false NOT NULL, + "last_login_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "course_memberships" ADD CONSTRAINT "course_memberships_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "course_memberships" ADD CONSTRAINT "course_memberships_course_id_courses_id_fk" FOREIGN KEY ("course_id") REFERENCES "public"."courses"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "courses" ADD CONSTRAINT "courses_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "lms_integrations" ADD CONSTRAINT "lms_integrations_course_id_courses_id_fk" FOREIGN KEY ("course_id") REFERENCES "public"."courses"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "lms_integrations" ADD CONSTRAINT "lms_integrations_api_credential_id_organization_credentials_id_fk" FOREIGN KEY ("api_credential_id") REFERENCES "public"."organization_credentials"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "lti_launches" ADD CONSTRAINT "lti_launches_lms_integration_id_lms_integrations_id_fk" FOREIGN KEY ("lms_integration_id") REFERENCES "public"."lms_integrations"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "lti_launches" ADD CONSTRAINT "lti_launches_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "organization_credentials" ADD CONSTRAINT "organization_credentials_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "course_memberships_user_course_uq" ON "course_memberships" USING btree ("user_id","course_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "course_memberships_canvas_enrollment_uq" ON "course_memberships" USING btree ("canvas_enrollment_id") WHERE "course_memberships"."canvas_enrollment_id" IS NOT NULL;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "course_memberships_course_idx" ON "course_memberships" USING btree ("course_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "courses_org_idx" ON "courses" USING btree ("organization_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "courses_org_canvas_course_uq" ON "courses" USING btree ("organization_id","canvas_course_id") WHERE "courses"."canvas_course_id" IS NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "lms_integrations_course_uq" ON "lms_integrations" USING btree ("course_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "lms_integrations_iss_client_deployment_uq" ON "lms_integrations" USING btree ("lti_iss","lti_client_id","lti_deployment_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "lti_launches_nonce_uq" ON "lti_launches" USING btree ("nonce");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "lti_launches_integration_idx" ON "lti_launches" USING btree ("lms_integration_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "lti_launches_user_idx" ON "lti_launches" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "organization_credentials_org_idx" ON "organization_credentials" USING btree ("organization_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "organization_credentials_org_provider_label_uq" ON "organization_credentials" USING btree ("organization_id","provider","label");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "organizations_slug_uq" ON "organizations" USING btree ("slug");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "organizations_workos_org_uq" ON "organizations" USING btree ("workos_organization_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "users_email_blind_index_uq" ON "users" USING btree ("email_blind_index");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "users_workos_user_uq" ON "users" USING btree ("workos_user_id") WHERE "users"."workos_user_id" IS NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "users_netid_blind_index_uq" ON "users" USING btree ("netid_blind_index") WHERE "users"."netid_blind_index" IS NOT NULL; \ No newline at end of file diff --git a/apps/web/src/db/migrations/0001_watery_lethal_legion.sql b/apps/web/src/db/migrations/0001_watery_lethal_legion.sql new file mode 100644 index 00000000..137c4dba --- /dev/null +++ b/apps/web/src/db/migrations/0001_watery_lethal_legion.sql @@ -0,0 +1,234 @@ +CREATE TYPE "public"."llm_provider" AS ENUM('openai', 'anthropic', 'claude_for_education', 'openrouter', 'local');--> statement-breakpoint +CREATE TYPE "public"."material_source_type" AS ENUM('pdf', 'slides', 'transcript', 'syllabus', 'other');--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "agent_definitions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "name" text NOT NULL, + "role_description" text NOT NULL, + "default_prompt_template_id" uuid, + "default_llm_config_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "course_materials" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "course_id" uuid NOT NULL, + "uploaded_by_id" uuid NOT NULL, + "source_type" "material_source_type" NOT NULL, + "title" text NOT NULL, + "original_filename" text, + "upload_metadata" jsonb, + "uploaded_at" timestamp with time zone DEFAULT now() NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "homeworks" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "course_id" uuid NOT NULL, + "created_by_id" uuid NOT NULL, + "prompt_template_id" uuid, + "llm_config_id" uuid, + "title" text NOT NULL, + "description" text NOT NULL, + "due_date" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "llm_configs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "provider" "llm_provider" NOT NULL, + "model_name" text NOT NULL, + "temperature" double precision DEFAULT 0.7 NOT NULL, + "max_completion_tokens" integer DEFAULT 1000 NOT NULL, + "credential_id" uuid, + "is_default" boolean DEFAULT false NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "llm_configs_temperature_range_chk" CHECK ("llm_configs"."temperature" >= 0 AND "llm_configs"."temperature" <= 2) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "material_chunks" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "material_id" uuid NOT NULL, + "ordinal" integer NOT NULL, + "text" text NOT NULL, + "embedding" vector(1536), + "token_count" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "prompt_templates" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "scope_organization_id" uuid, + "scope_course_id" uuid, + "scope_homework_id" uuid, + "scope_section_id" uuid, + "previous_version_id" uuid, + "version" integer DEFAULT 1 NOT NULL, + "content" text NOT NULL, + "compose_with_parent" boolean DEFAULT false NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "prompt_templates_exactly_one_scope_chk" CHECK (num_nonnulls("prompt_templates"."scope_organization_id", "prompt_templates"."scope_course_id", "prompt_templates"."scope_homework_id", "prompt_templates"."scope_section_id") = 1) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "section_solutions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "section_id" uuid NOT NULL, + "content" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "sections" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "homework_id" uuid NOT NULL, + "prompt_template_id" uuid, + "order" integer NOT NULL, + "title" text NOT NULL, + "content" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "sections_order_range_chk" CHECK ("sections"."order" >= 1 AND "sections"."order" <= 20) +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "agent_definitions" ADD CONSTRAINT "agent_definitions_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "agent_definitions" ADD CONSTRAINT "agent_definitions_default_prompt_template_id_prompt_templates_id_fk" FOREIGN KEY ("default_prompt_template_id") REFERENCES "public"."prompt_templates"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "agent_definitions" ADD CONSTRAINT "agent_definitions_default_llm_config_id_llm_configs_id_fk" FOREIGN KEY ("default_llm_config_id") REFERENCES "public"."llm_configs"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "course_materials" ADD CONSTRAINT "course_materials_course_id_courses_id_fk" FOREIGN KEY ("course_id") REFERENCES "public"."courses"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "course_materials" ADD CONSTRAINT "course_materials_uploaded_by_id_course_memberships_id_fk" FOREIGN KEY ("uploaded_by_id") REFERENCES "public"."course_memberships"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "homeworks" ADD CONSTRAINT "homeworks_course_id_courses_id_fk" FOREIGN KEY ("course_id") REFERENCES "public"."courses"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "homeworks" ADD CONSTRAINT "homeworks_created_by_id_course_memberships_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."course_memberships"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "homeworks" ADD CONSTRAINT "homeworks_prompt_template_id_prompt_templates_id_fk" FOREIGN KEY ("prompt_template_id") REFERENCES "public"."prompt_templates"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "homeworks" ADD CONSTRAINT "homeworks_llm_config_id_llm_configs_id_fk" FOREIGN KEY ("llm_config_id") REFERENCES "public"."llm_configs"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "llm_configs" ADD CONSTRAINT "llm_configs_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "llm_configs" ADD CONSTRAINT "llm_configs_credential_id_organization_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."organization_credentials"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "material_chunks" ADD CONSTRAINT "material_chunks_material_id_course_materials_id_fk" FOREIGN KEY ("material_id") REFERENCES "public"."course_materials"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "prompt_templates" ADD CONSTRAINT "prompt_templates_scope_organization_id_organizations_id_fk" FOREIGN KEY ("scope_organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "prompt_templates" ADD CONSTRAINT "prompt_templates_scope_course_id_courses_id_fk" FOREIGN KEY ("scope_course_id") REFERENCES "public"."courses"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "prompt_templates" ADD CONSTRAINT "prompt_templates_scope_homework_id_homeworks_id_fk" FOREIGN KEY ("scope_homework_id") REFERENCES "public"."homeworks"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "prompt_templates" ADD CONSTRAINT "prompt_templates_scope_section_id_sections_id_fk" FOREIGN KEY ("scope_section_id") REFERENCES "public"."sections"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "prompt_templates" ADD CONSTRAINT "prompt_templates_previous_version_id_prompt_templates_id_fk" FOREIGN KEY ("previous_version_id") REFERENCES "public"."prompt_templates"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "section_solutions" ADD CONSTRAINT "section_solutions_section_id_sections_id_fk" FOREIGN KEY ("section_id") REFERENCES "public"."sections"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "sections" ADD CONSTRAINT "sections_homework_id_homeworks_id_fk" FOREIGN KEY ("homework_id") REFERENCES "public"."homeworks"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "sections" ADD CONSTRAINT "sections_prompt_template_id_prompt_templates_id_fk" FOREIGN KEY ("prompt_template_id") REFERENCES "public"."prompt_templates"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "agent_definitions_org_name_uq" ON "agent_definitions" USING btree ("organization_id","name");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "course_materials_course_idx" ON "course_materials" USING btree ("course_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "homeworks_course_idx" ON "homeworks" USING btree ("course_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "homeworks_created_by_idx" ON "homeworks" USING btree ("created_by_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "llm_configs_org_idx" ON "llm_configs" USING btree ("organization_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "llm_configs_org_default_uq" ON "llm_configs" USING btree ("organization_id") WHERE "llm_configs"."is_default" = true;--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "material_chunks_material_ordinal_uq" ON "material_chunks" USING btree ("material_id","ordinal");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "material_chunks_material_idx" ON "material_chunks" USING btree ("material_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "prompt_templates_scope_org_idx" ON "prompt_templates" USING btree ("scope_organization_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "prompt_templates_scope_course_idx" ON "prompt_templates" USING btree ("scope_course_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "prompt_templates_scope_homework_idx" ON "prompt_templates" USING btree ("scope_homework_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "prompt_templates_scope_section_idx" ON "prompt_templates" USING btree ("scope_section_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "section_solutions_section_uq" ON "section_solutions" USING btree ("section_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "sections_homework_order_uq" ON "sections" USING btree ("homework_id","order"); \ No newline at end of file diff --git a/apps/web/src/db/migrations/meta/0000_snapshot.json b/apps/web/src/db/migrations/meta/0000_snapshot.json new file mode 100644 index 00000000..a37f6cd7 --- /dev/null +++ b/apps/web/src/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,1011 @@ +{ + "id": "d95cffc6-102e-4dce-9ef8-43ac8384efb1", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.pings": { + "name": "pings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.course_memberships": { + "name": "course_memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "course_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "canvas_enrollment_id": { + "name": "canvas_enrollment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "canvas_role": { + "name": "canvas_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "dropped_at": { + "name": "dropped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "course_memberships_user_course_uq": { + "name": "course_memberships_user_course_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "course_memberships_canvas_enrollment_uq": { + "name": "course_memberships_canvas_enrollment_uq", + "columns": [ + { + "expression": "canvas_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"course_memberships\".\"canvas_enrollment_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "course_memberships_course_idx": { + "name": "course_memberships_course_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "course_memberships_user_id_users_id_fk": { + "name": "course_memberships_user_id_users_id_fk", + "tableFrom": "course_memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "course_memberships_course_id_courses_id_fk": { + "name": "course_memberships_course_id_courses_id_fk", + "tableFrom": "course_memberships", + "tableTo": "courses", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.courses": { + "name": "courses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "canvas_course_id": { + "name": "canvas_course_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "term": { + "name": "term", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "courses_org_idx": { + "name": "courses_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "courses_org_canvas_course_uq": { + "name": "courses_org_canvas_course_uq", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "canvas_course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"courses\".\"canvas_course_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "courses_organization_id_organizations_id_fk": { + "name": "courses_organization_id_organizations_id_fk", + "tableFrom": "courses", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lms_integrations": { + "name": "lms_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "lms_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'canvas'" + }, + "lti_iss": { + "name": "lti_iss", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lti_client_id": { + "name": "lti_client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lti_deployment_id": { + "name": "lti_deployment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_credential_id": { + "name": "api_credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "lms_integrations_course_uq": { + "name": "lms_integrations_course_uq", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "lms_integrations_iss_client_deployment_uq": { + "name": "lms_integrations_iss_client_deployment_uq", + "columns": [ + { + "expression": "lti_iss", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lti_client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lti_deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lms_integrations_course_id_courses_id_fk": { + "name": "lms_integrations_course_id_courses_id_fk", + "tableFrom": "lms_integrations", + "tableTo": "courses", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lms_integrations_api_credential_id_organization_credentials_id_fk": { + "name": "lms_integrations_api_credential_id_organization_credentials_id_fk", + "tableFrom": "lms_integrations", + "tableTo": "organization_credentials", + "columnsFrom": [ + "api_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lti_launches": { + "name": "lti_launches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "lms_integration_id": { + "name": "lms_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_link_id": { + "name": "resource_link_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role_claim": { + "name": "role_claim", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "lti_launches_nonce_uq": { + "name": "lti_launches_nonce_uq", + "columns": [ + { + "expression": "nonce", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "lti_launches_integration_idx": { + "name": "lti_launches_integration_idx", + "columns": [ + { + "expression": "lms_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "lti_launches_user_idx": { + "name": "lti_launches_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lti_launches_lms_integration_id_lms_integrations_id_fk": { + "name": "lti_launches_lms_integration_id_lms_integrations_id_fk", + "tableFrom": "lti_launches", + "tableTo": "lms_integrations", + "columnsFrom": [ + "lms_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lti_launches_user_id_users_id_fk": { + "name": "lti_launches_user_id_users_id_fk", + "tableFrom": "lti_launches", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_credentials": { + "name": "organization_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "credential_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_ref": { + "name": "secret_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rotated_at": { + "name": "rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_credentials_org_idx": { + "name": "organization_credentials_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_credentials_org_provider_label_uq": { + "name": "organization_credentials_org_provider_label_uq", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "label", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_credentials_organization_id_organizations_id_fk": { + "name": "organization_credentials_organization_id_organizations_id_fk", + "tableFrom": "organization_credentials", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workos_organization_id": { + "name": "workos_organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canvas_account_id": { + "name": "canvas_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requires_ferpa": { + "name": "requires_ferpa", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "requires_hipaa": { + "name": "requires_hipaa", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "data_residency": { + "name": "data_residency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_uq": { + "name": "organizations_slug_uq", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organizations_workos_org_uq": { + "name": "organizations_workos_org_uq", + "columns": [ + { + "expression": "workos_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workos_user_id": { + "name": "workos_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "email_blind_index": { + "name": "email_blind_index", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "netid": { + "name": "netid", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "netid_blind_index": { + "name": "netid_blind_index", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "is_pending": { + "name": "is_pending", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_blind_index_uq": { + "name": "users_email_blind_index_uq", + "columns": [ + { + "expression": "email_blind_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_workos_user_uq": { + "name": "users_workos_user_uq", + "columns": [ + { + "expression": "workos_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"users\".\"workos_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_netid_blind_index_uq": { + "name": "users_netid_blind_index_uq", + "columns": [ + { + "expression": "netid_blind_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"users\".\"netid_blind_index\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.course_role": { + "name": "course_role", + "schema": "public", + "values": [ + "instructor", + "ta", + "student", + "observer", + "admin" + ] + }, + "public.credential_provider": { + "name": "credential_provider", + "schema": "public", + "values": [ + "openai", + "anthropic", + "claude_for_education", + "openrouter", + "local", + "canvas", + "workos" + ] + }, + "public.lms_provider": { + "name": "lms_provider", + "schema": "public", + "values": [ + "canvas" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/web/src/db/migrations/meta/0001_snapshot.json b/apps/web/src/db/migrations/meta/0001_snapshot.json new file mode 100644 index 00000000..eedeafcb --- /dev/null +++ b/apps/web/src/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,2116 @@ +{ + "id": "c828e00a-3124-43df-8cd6-d03be7fa726e", + "prevId": "d95cffc6-102e-4dce-9ef8-43ac8384efb1", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.pings": { + "name": "pings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.course_memberships": { + "name": "course_memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "course_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "canvas_enrollment_id": { + "name": "canvas_enrollment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "canvas_role": { + "name": "canvas_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "dropped_at": { + "name": "dropped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "course_memberships_user_course_uq": { + "name": "course_memberships_user_course_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "course_memberships_canvas_enrollment_uq": { + "name": "course_memberships_canvas_enrollment_uq", + "columns": [ + { + "expression": "canvas_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"course_memberships\".\"canvas_enrollment_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "course_memberships_course_idx": { + "name": "course_memberships_course_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "course_memberships_user_id_users_id_fk": { + "name": "course_memberships_user_id_users_id_fk", + "tableFrom": "course_memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "course_memberships_course_id_courses_id_fk": { + "name": "course_memberships_course_id_courses_id_fk", + "tableFrom": "course_memberships", + "tableTo": "courses", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.courses": { + "name": "courses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "canvas_course_id": { + "name": "canvas_course_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "term": { + "name": "term", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "courses_org_idx": { + "name": "courses_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "courses_org_canvas_course_uq": { + "name": "courses_org_canvas_course_uq", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "canvas_course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"courses\".\"canvas_course_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "courses_organization_id_organizations_id_fk": { + "name": "courses_organization_id_organizations_id_fk", + "tableFrom": "courses", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lms_integrations": { + "name": "lms_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "lms_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'canvas'" + }, + "lti_iss": { + "name": "lti_iss", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lti_client_id": { + "name": "lti_client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lti_deployment_id": { + "name": "lti_deployment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_credential_id": { + "name": "api_credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "lms_integrations_course_uq": { + "name": "lms_integrations_course_uq", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "lms_integrations_iss_client_deployment_uq": { + "name": "lms_integrations_iss_client_deployment_uq", + "columns": [ + { + "expression": "lti_iss", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lti_client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lti_deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lms_integrations_course_id_courses_id_fk": { + "name": "lms_integrations_course_id_courses_id_fk", + "tableFrom": "lms_integrations", + "tableTo": "courses", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lms_integrations_api_credential_id_organization_credentials_id_fk": { + "name": "lms_integrations_api_credential_id_organization_credentials_id_fk", + "tableFrom": "lms_integrations", + "tableTo": "organization_credentials", + "columnsFrom": [ + "api_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lti_launches": { + "name": "lti_launches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "lms_integration_id": { + "name": "lms_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_link_id": { + "name": "resource_link_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role_claim": { + "name": "role_claim", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "lti_launches_nonce_uq": { + "name": "lti_launches_nonce_uq", + "columns": [ + { + "expression": "nonce", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "lti_launches_integration_idx": { + "name": "lti_launches_integration_idx", + "columns": [ + { + "expression": "lms_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "lti_launches_user_idx": { + "name": "lti_launches_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lti_launches_lms_integration_id_lms_integrations_id_fk": { + "name": "lti_launches_lms_integration_id_lms_integrations_id_fk", + "tableFrom": "lti_launches", + "tableTo": "lms_integrations", + "columnsFrom": [ + "lms_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lti_launches_user_id_users_id_fk": { + "name": "lti_launches_user_id_users_id_fk", + "tableFrom": "lti_launches", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_credentials": { + "name": "organization_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "credential_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_ref": { + "name": "secret_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rotated_at": { + "name": "rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_credentials_org_idx": { + "name": "organization_credentials_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_credentials_org_provider_label_uq": { + "name": "organization_credentials_org_provider_label_uq", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "label", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_credentials_organization_id_organizations_id_fk": { + "name": "organization_credentials_organization_id_organizations_id_fk", + "tableFrom": "organization_credentials", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workos_organization_id": { + "name": "workos_organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canvas_account_id": { + "name": "canvas_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requires_ferpa": { + "name": "requires_ferpa", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "requires_hipaa": { + "name": "requires_hipaa", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "data_residency": { + "name": "data_residency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_uq": { + "name": "organizations_slug_uq", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organizations_workos_org_uq": { + "name": "organizations_workos_org_uq", + "columns": [ + { + "expression": "workos_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workos_user_id": { + "name": "workos_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "email_blind_index": { + "name": "email_blind_index", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "netid": { + "name": "netid", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "netid_blind_index": { + "name": "netid_blind_index", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "is_pending": { + "name": "is_pending", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_blind_index_uq": { + "name": "users_email_blind_index_uq", + "columns": [ + { + "expression": "email_blind_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_workos_user_uq": { + "name": "users_workos_user_uq", + "columns": [ + { + "expression": "workos_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"users\".\"workos_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_netid_blind_index_uq": { + "name": "users_netid_blind_index_uq", + "columns": [ + { + "expression": "netid_blind_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"users\".\"netid_blind_index\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_definitions": { + "name": "agent_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_prompt_template_id": { + "name": "default_prompt_template_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "default_llm_config_id": { + "name": "default_llm_config_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_definitions_org_name_uq": { + "name": "agent_definitions_org_name_uq", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_definitions_organization_id_organizations_id_fk": { + "name": "agent_definitions_organization_id_organizations_id_fk", + "tableFrom": "agent_definitions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_definitions_default_prompt_template_id_prompt_templates_id_fk": { + "name": "agent_definitions_default_prompt_template_id_prompt_templates_id_fk", + "tableFrom": "agent_definitions", + "tableTo": "prompt_templates", + "columnsFrom": [ + "default_prompt_template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "agent_definitions_default_llm_config_id_llm_configs_id_fk": { + "name": "agent_definitions_default_llm_config_id_llm_configs_id_fk", + "tableFrom": "agent_definitions", + "tableTo": "llm_configs", + "columnsFrom": [ + "default_llm_config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.course_materials": { + "name": "course_materials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "uploaded_by_id": { + "name": "uploaded_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_type": { + "name": "source_type", + "type": "material_source_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "upload_metadata": { + "name": "upload_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "course_materials_course_idx": { + "name": "course_materials_course_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "course_materials_course_id_courses_id_fk": { + "name": "course_materials_course_id_courses_id_fk", + "tableFrom": "course_materials", + "tableTo": "courses", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "course_materials_uploaded_by_id_course_memberships_id_fk": { + "name": "course_materials_uploaded_by_id_course_memberships_id_fk", + "tableFrom": "course_materials", + "tableTo": "course_memberships", + "columnsFrom": [ + "uploaded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.homeworks": { + "name": "homeworks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "prompt_template_id": { + "name": "prompt_template_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "llm_config_id": { + "name": "llm_config_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "due_date": { + "name": "due_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "homeworks_course_idx": { + "name": "homeworks_course_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "homeworks_created_by_idx": { + "name": "homeworks_created_by_idx", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "homeworks_course_id_courses_id_fk": { + "name": "homeworks_course_id_courses_id_fk", + "tableFrom": "homeworks", + "tableTo": "courses", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "homeworks_created_by_id_course_memberships_id_fk": { + "name": "homeworks_created_by_id_course_memberships_id_fk", + "tableFrom": "homeworks", + "tableTo": "course_memberships", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "homeworks_prompt_template_id_prompt_templates_id_fk": { + "name": "homeworks_prompt_template_id_prompt_templates_id_fk", + "tableFrom": "homeworks", + "tableTo": "prompt_templates", + "columnsFrom": [ + "prompt_template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "homeworks_llm_config_id_llm_configs_id_fk": { + "name": "homeworks_llm_config_id_llm_configs_id_fk", + "tableFrom": "homeworks", + "tableTo": "llm_configs", + "columnsFrom": [ + "llm_config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.llm_configs": { + "name": "llm_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "llm_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "model_name": { + "name": "model_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "temperature": { + "name": "temperature", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0.7 + }, + "max_completion_tokens": { + "name": "max_completion_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1000 + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "llm_configs_org_idx": { + "name": "llm_configs_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "llm_configs_org_default_uq": { + "name": "llm_configs_org_default_uq", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"llm_configs\".\"is_default\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "llm_configs_organization_id_organizations_id_fk": { + "name": "llm_configs_organization_id_organizations_id_fk", + "tableFrom": "llm_configs", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "llm_configs_credential_id_organization_credentials_id_fk": { + "name": "llm_configs_credential_id_organization_credentials_id_fk", + "tableFrom": "llm_configs", + "tableTo": "organization_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "llm_configs_temperature_range_chk": { + "name": "llm_configs_temperature_range_chk", + "value": "\"llm_configs\".\"temperature\" >= 0 AND \"llm_configs\".\"temperature\" <= 2" + } + }, + "isRLSEnabled": false + }, + "public.material_chunks": { + "name": "material_chunks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "material_id": { + "name": "material_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "material_chunks_material_ordinal_uq": { + "name": "material_chunks_material_ordinal_uq", + "columns": [ + { + "expression": "material_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ordinal", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "material_chunks_material_idx": { + "name": "material_chunks_material_idx", + "columns": [ + { + "expression": "material_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "material_chunks_material_id_course_materials_id_fk": { + "name": "material_chunks_material_id_course_materials_id_fk", + "tableFrom": "material_chunks", + "tableTo": "course_materials", + "columnsFrom": [ + "material_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.prompt_templates": { + "name": "prompt_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "scope_organization_id": { + "name": "scope_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scope_course_id": { + "name": "scope_course_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scope_homework_id": { + "name": "scope_homework_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scope_section_id": { + "name": "scope_section_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "previous_version_id": { + "name": "previous_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "compose_with_parent": { + "name": "compose_with_parent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "prompt_templates_scope_org_idx": { + "name": "prompt_templates_scope_org_idx", + "columns": [ + { + "expression": "scope_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "prompt_templates_scope_course_idx": { + "name": "prompt_templates_scope_course_idx", + "columns": [ + { + "expression": "scope_course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "prompt_templates_scope_homework_idx": { + "name": "prompt_templates_scope_homework_idx", + "columns": [ + { + "expression": "scope_homework_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "prompt_templates_scope_section_idx": { + "name": "prompt_templates_scope_section_idx", + "columns": [ + { + "expression": "scope_section_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "prompt_templates_scope_organization_id_organizations_id_fk": { + "name": "prompt_templates_scope_organization_id_organizations_id_fk", + "tableFrom": "prompt_templates", + "tableTo": "organizations", + "columnsFrom": [ + "scope_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "prompt_templates_scope_course_id_courses_id_fk": { + "name": "prompt_templates_scope_course_id_courses_id_fk", + "tableFrom": "prompt_templates", + "tableTo": "courses", + "columnsFrom": [ + "scope_course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "prompt_templates_scope_homework_id_homeworks_id_fk": { + "name": "prompt_templates_scope_homework_id_homeworks_id_fk", + "tableFrom": "prompt_templates", + "tableTo": "homeworks", + "columnsFrom": [ + "scope_homework_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "prompt_templates_scope_section_id_sections_id_fk": { + "name": "prompt_templates_scope_section_id_sections_id_fk", + "tableFrom": "prompt_templates", + "tableTo": "sections", + "columnsFrom": [ + "scope_section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "prompt_templates_previous_version_id_prompt_templates_id_fk": { + "name": "prompt_templates_previous_version_id_prompt_templates_id_fk", + "tableFrom": "prompt_templates", + "tableTo": "prompt_templates", + "columnsFrom": [ + "previous_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "prompt_templates_exactly_one_scope_chk": { + "name": "prompt_templates_exactly_one_scope_chk", + "value": "num_nonnulls(\"prompt_templates\".\"scope_organization_id\", \"prompt_templates\".\"scope_course_id\", \"prompt_templates\".\"scope_homework_id\", \"prompt_templates\".\"scope_section_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.section_solutions": { + "name": "section_solutions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "section_id": { + "name": "section_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "section_solutions_section_uq": { + "name": "section_solutions_section_uq", + "columns": [ + { + "expression": "section_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "section_solutions_section_id_sections_id_fk": { + "name": "section_solutions_section_id_sections_id_fk", + "tableFrom": "section_solutions", + "tableTo": "sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sections": { + "name": "sections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "homework_id": { + "name": "homework_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "prompt_template_id": { + "name": "prompt_template_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sections_homework_order_uq": { + "name": "sections_homework_order_uq", + "columns": [ + { + "expression": "homework_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sections_homework_id_homeworks_id_fk": { + "name": "sections_homework_id_homeworks_id_fk", + "tableFrom": "sections", + "tableTo": "homeworks", + "columnsFrom": [ + "homework_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sections_prompt_template_id_prompt_templates_id_fk": { + "name": "sections_prompt_template_id_prompt_templates_id_fk", + "tableFrom": "sections", + "tableTo": "prompt_templates", + "columnsFrom": [ + "prompt_template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sections_order_range_chk": { + "name": "sections_order_range_chk", + "value": "\"sections\".\"order\" >= 1 AND \"sections\".\"order\" <= 20" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.course_role": { + "name": "course_role", + "schema": "public", + "values": [ + "instructor", + "ta", + "student", + "observer", + "admin" + ] + }, + "public.credential_provider": { + "name": "credential_provider", + "schema": "public", + "values": [ + "openai", + "anthropic", + "claude_for_education", + "openrouter", + "local", + "canvas", + "workos" + ] + }, + "public.lms_provider": { + "name": "lms_provider", + "schema": "public", + "values": [ + "canvas" + ] + }, + "public.llm_provider": { + "name": "llm_provider", + "schema": "public", + "values": [ + "openai", + "anthropic", + "claude_for_education", + "openrouter", + "local" + ] + }, + "public.material_source_type": { + "name": "material_source_type", + "schema": "public", + "values": [ + "pdf", + "slides", + "transcript", + "syllabus", + "other" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/web/src/db/migrations/meta/_journal.json b/apps/web/src/db/migrations/meta/_journal.json new file mode 100644 index 00000000..e302ea13 --- /dev/null +++ b/apps/web/src/db/migrations/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1780517632446, + "tag": "0000_majestic_mentallo", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1780517802302, + "tag": "0001_watery_lethal_legion", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/apps/web/src/db/schema.ts b/apps/web/src/db/schema.ts new file mode 100644 index 00000000..c3a567fc --- /dev/null +++ b/apps/web/src/db/schema.ts @@ -0,0 +1,9 @@ +// Schema barrel. Drizzle-kit reads this file to discover all tables and +// relations; app code imports tables and inferred types from here. +// +// Domain modules live under ./schema/. Add new modules as separate files, +// then re-export them below. + +export * from "./schema/pings"; +export * from "./schema/identity"; +export * from "./schema/content"; diff --git a/apps/web/src/db/schema/content.ts b/apps/web/src/db/schema/content.ts new file mode 100644 index 00000000..18c95168 --- /dev/null +++ b/apps/web/src/db/schema/content.ts @@ -0,0 +1,465 @@ +import { relations, sql } from "drizzle-orm"; +import { + type AnyPgColumn, + boolean, + check, + doublePrecision, + index, + integer, + jsonb, + pgEnum, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, + vector, +} from "drizzle-orm/pg-core"; + +import { + courseMemberships, + courses, + organizationCredentials, + organizations, +} from "./identity"; + +// ---------- Enums ---------- + +export const llmProviderEnum = pgEnum("llm_provider", [ + "openai", + "anthropic", + "claude_for_education", + "openrouter", + "local", +]); + +export const materialSourceEnum = pgEnum("material_source_type", [ + "pdf", + "slides", + "transcript", + "syllabus", + "other", +]); + +// ---------- LLMConfig ---------- +// Per-Organization pool of model configurations. is_default is per-org; at +// most one row per org may have is_default = true (enforced via partial +// unique index). + +export const llmConfigs = pgTable( + "llm_configs", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + provider: llmProviderEnum("provider").notNull(), + modelName: text("model_name").notNull(), + temperature: doublePrecision("temperature").notNull().default(0.7), + maxCompletionTokens: integer("max_completion_tokens") + .notNull() + .default(1000), + credentialId: uuid("credential_id").references( + () => organizationCredentials.id, + { onDelete: "set null" }, + ), + isDefault: boolean("is_default").notNull().default(false), + isActive: boolean("is_active").notNull().default(true), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + index("llm_configs_org_idx").on(t.organizationId), + uniqueIndex("llm_configs_org_default_uq") + .on(t.organizationId) + .where(sql`${t.isDefault} = true`), + check( + "llm_configs_temperature_range_chk", + sql`${t.temperature} >= 0 AND ${t.temperature} <= 2`, + ), + ], +); + +// ---------- PromptTemplate ---------- +// Polymorphic scope: exactly one of (scope_organization_id, scope_course_id, +// scope_homework_id, scope_section_id) is non-null. Enforced by a CHECK using +// num_nonnulls(). Resolution at runtime walks section -> homework -> course +// -> org. +// +// Versioned via previous_version_id (self-FK). Each edit creates a new row +// pointing back to the prior version; existing Conversations pin to the +// version they started with (see §6.3 schema, runtime.ts). + +export const promptTemplates = pgTable( + "prompt_templates", + { + id: uuid("id").primaryKey().defaultRandom(), + + scopeOrganizationId: uuid("scope_organization_id").references( + () => organizations.id, + { onDelete: "cascade" }, + ), + scopeCourseId: uuid("scope_course_id").references(() => courses.id, { + onDelete: "cascade", + }), + scopeHomeworkId: uuid("scope_homework_id").references( + (): AnyPgColumn => homeworks.id, + { onDelete: "cascade" }, + ), + scopeSectionId: uuid("scope_section_id").references( + (): AnyPgColumn => sections.id, + { onDelete: "cascade" }, + ), + + previousVersionId: uuid("previous_version_id").references( + (): AnyPgColumn => promptTemplates.id, + { onDelete: "set null" }, + ), + version: integer("version").notNull().default(1), + content: text("content").notNull(), + composeWithParent: boolean("compose_with_parent").notNull().default(false), + isActive: boolean("is_active").notNull().default(true), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + check( + "prompt_templates_exactly_one_scope_chk", + sql`num_nonnulls(${t.scopeOrganizationId}, ${t.scopeCourseId}, ${t.scopeHomeworkId}, ${t.scopeSectionId}) = 1`, + ), + index("prompt_templates_scope_org_idx").on(t.scopeOrganizationId), + index("prompt_templates_scope_course_idx").on(t.scopeCourseId), + index("prompt_templates_scope_homework_idx").on(t.scopeHomeworkId), + index("prompt_templates_scope_section_idx").on(t.scopeSectionId), + ], +); + +// ---------- Homework ---------- +// Assignment owned by a Course. prompt_template_id and llm_config_id are +// nullable overrides; resolution falls back to Course / Organization defaults +// when null. created_by_id references a CourseMembership (not a User), so a +// TA or co-instructor authoring an assignment is first-class. + +export const homeworks = pgTable( + "homeworks", + { + id: uuid("id").primaryKey().defaultRandom(), + courseId: uuid("course_id") + .notNull() + .references(() => courses.id, { onDelete: "cascade" }), + createdById: uuid("created_by_id") + .notNull() + .references(() => courseMemberships.id, { onDelete: "restrict" }), + promptTemplateId: uuid("prompt_template_id").references( + () => promptTemplates.id, + { onDelete: "set null" }, + ), + llmConfigId: uuid("llm_config_id").references(() => llmConfigs.id, { + onDelete: "set null", + }), + title: text("title").notNull(), + description: text("description").notNull(), + dueDate: timestamp("due_date", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + index("homeworks_course_idx").on(t.courseId), + index("homeworks_created_by_idx").on(t.createdById), + ], +); + +// ---------- Section ---------- +// Ordered sub-part of a Homework. Sara's level-2 (problem-specific) prompts +// live as PromptTemplate rows scoped to a section. order is 1-indexed, +// capped at 20 to match the legacy Django constraint. + +export const sections = pgTable( + "sections", + { + id: uuid("id").primaryKey().defaultRandom(), + homeworkId: uuid("homework_id") + .notNull() + .references(() => homeworks.id, { onDelete: "cascade" }), + promptTemplateId: uuid("prompt_template_id").references( + () => promptTemplates.id, + { onDelete: "set null" }, + ), + order: integer("order").notNull(), + title: text("title").notNull(), + content: text("content").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + uniqueIndex("sections_homework_order_uq").on(t.homeworkId, t.order), + check( + "sections_order_range_chk", + sql`${t.order} >= 1 AND ${t.order} <= 20`, + ), + ], +); + +// ---------- SectionSolution ---------- +// Teacher-provided model solution. Optional; 1:1 with Section when present. +// FK lives on this side (vs. on Section) so a Section can exist without a +// Solution but every Solution is bound to exactly one Section. + +export const sectionSolutions = pgTable( + "section_solutions", + { + id: uuid("id").primaryKey().defaultRandom(), + sectionId: uuid("section_id") + .notNull() + .references(() => sections.id, { onDelete: "cascade" }), + content: text("content").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [uniqueIndex("section_solutions_section_uq").on(t.sectionId)], +); + +// ---------- CourseMaterial ---------- +// Uploaded course artifact (PDF, slide deck, lecture transcript, etc.) used +// as RAG grounding. upload_metadata is open-ended jsonb (page count, mime, +// extraction notes); structure it via a Zod schema at the route layer rather +// than constraining the column. + +export const courseMaterials = pgTable( + "course_materials", + { + id: uuid("id").primaryKey().defaultRandom(), + courseId: uuid("course_id") + .notNull() + .references(() => courses.id, { onDelete: "cascade" }), + uploadedById: uuid("uploaded_by_id") + .notNull() + .references(() => courseMemberships.id, { onDelete: "restrict" }), + sourceType: materialSourceEnum("source_type").notNull(), + title: text("title").notNull(), + originalFilename: text("original_filename"), + uploadMetadata: jsonb("upload_metadata"), + uploadedAt: timestamp("uploaded_at", { withTimezone: true }) + .notNull() + .defaultNow(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [index("course_materials_course_idx").on(t.courseId)], +); + +// ---------- MaterialChunk ---------- +// Chunked + embedded text from a CourseMaterial. Vector embedding via pgvector +// (extension must be enabled; see apps/web/src/db/init/01_extensions.sql). +// Default dimension = 1536 (OpenAI text-embedding-3-small); changing requires +// a migration. + +export const materialChunks = pgTable( + "material_chunks", + { + id: uuid("id").primaryKey().defaultRandom(), + materialId: uuid("material_id") + .notNull() + .references(() => courseMaterials.id, { onDelete: "cascade" }), + ordinal: integer("ordinal").notNull(), + text: text("text").notNull(), + embedding: vector("embedding", { dimensions: 1536 }), + tokenCount: integer("token_count").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + uniqueIndex("material_chunks_material_ordinal_uq").on( + t.materialId, + t.ordinal, + ), + index("material_chunks_material_idx").on(t.materialId), + ], +); + +// ---------- AgentDefinition ---------- +// Per-Organization registry of multi-agent personas (tutor, transcript +// evaluator, profile builder, ...). Each persona has a default prompt and +// LLM config; per-conversation overrides happen at the ConversationAgent +// row in §6.3. + +export const agentDefinitions = pgTable( + "agent_definitions", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + name: text("name").notNull(), + roleDescription: text("role_description").notNull(), + defaultPromptTemplateId: uuid("default_prompt_template_id").references( + () => promptTemplates.id, + { onDelete: "set null" }, + ), + defaultLlmConfigId: uuid("default_llm_config_id").references( + () => llmConfigs.id, + { onDelete: "set null" }, + ), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + uniqueIndex("agent_definitions_org_name_uq").on(t.organizationId, t.name), + ], +); + +// ---------- Relations ---------- + +export const llmConfigsRelations = relations(llmConfigs, ({ one, many }) => ({ + organization: one(organizations, { + fields: [llmConfigs.organizationId], + references: [organizations.id], + }), + credential: one(organizationCredentials, { + fields: [llmConfigs.credentialId], + references: [organizationCredentials.id], + }), + homeworks: many(homeworks), + agentDefinitions: many(agentDefinitions), +})); + +export const promptTemplatesRelations = relations( + promptTemplates, + ({ one, many }) => ({ + scopeOrganization: one(organizations, { + fields: [promptTemplates.scopeOrganizationId], + references: [organizations.id], + }), + scopeCourse: one(courses, { + fields: [promptTemplates.scopeCourseId], + references: [courses.id], + }), + scopeHomework: one(homeworks, { + fields: [promptTemplates.scopeHomeworkId], + references: [homeworks.id], + }), + scopeSection: one(sections, { + fields: [promptTemplates.scopeSectionId], + references: [sections.id], + }), + previousVersion: one(promptTemplates, { + fields: [promptTemplates.previousVersionId], + references: [promptTemplates.id], + relationName: "prompt_template_history", + }), + nextVersions: many(promptTemplates, { + relationName: "prompt_template_history", + }), + }), +); + +export const homeworksRelations = relations(homeworks, ({ one, many }) => ({ + course: one(courses, { + fields: [homeworks.courseId], + references: [courses.id], + }), + createdBy: one(courseMemberships, { + fields: [homeworks.createdById], + references: [courseMemberships.id], + }), + promptTemplate: one(promptTemplates, { + fields: [homeworks.promptTemplateId], + references: [promptTemplates.id], + }), + llmConfig: one(llmConfigs, { + fields: [homeworks.llmConfigId], + references: [llmConfigs.id], + }), + sections: many(sections), +})); + +export const sectionsRelations = relations(sections, ({ one }) => ({ + homework: one(homeworks, { + fields: [sections.homeworkId], + references: [homeworks.id], + }), + promptTemplate: one(promptTemplates, { + fields: [sections.promptTemplateId], + references: [promptTemplates.id], + }), + solution: one(sectionSolutions), +})); + +export const sectionSolutionsRelations = relations( + sectionSolutions, + ({ one }) => ({ + section: one(sections, { + fields: [sectionSolutions.sectionId], + references: [sections.id], + }), + }), +); + +export const courseMaterialsRelations = relations( + courseMaterials, + ({ one, many }) => ({ + course: one(courses, { + fields: [courseMaterials.courseId], + references: [courses.id], + }), + uploadedBy: one(courseMemberships, { + fields: [courseMaterials.uploadedById], + references: [courseMemberships.id], + }), + chunks: many(materialChunks), + }), +); + +export const materialChunksRelations = relations(materialChunks, ({ one }) => ({ + material: one(courseMaterials, { + fields: [materialChunks.materialId], + references: [courseMaterials.id], + }), +})); + +export const agentDefinitionsRelations = relations( + agentDefinitions, + ({ one }) => ({ + organization: one(organizations, { + fields: [agentDefinitions.organizationId], + references: [organizations.id], + }), + defaultPromptTemplate: one(promptTemplates, { + fields: [agentDefinitions.defaultPromptTemplateId], + references: [promptTemplates.id], + }), + defaultLlmConfig: one(llmConfigs, { + fields: [agentDefinitions.defaultLlmConfigId], + references: [llmConfigs.id], + }), + }), +); diff --git a/apps/web/src/db/schema/identity.ts b/apps/web/src/db/schema/identity.ts new file mode 100644 index 00000000..43b1428b --- /dev/null +++ b/apps/web/src/db/schema/identity.ts @@ -0,0 +1,357 @@ +import { relations, sql } from "drizzle-orm"; +import { + boolean, + index, + pgEnum, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; + +import { blindIndex, encryptedText } from "../types/encrypted"; + +// ---------- PII handling convention ---------- +// All directly identifying PII (name, email, NetID) is stored encrypted via +// `encryptedText`. Equality lookups use a sibling `blindIndex` column populated +// at write time with HMAC-SHA256 of the normalized plaintext. Range or LIKE +// queries on encrypted columns are not supported -- design admin search around +// equality on a blind index. +// +// Do not add a plaintext column for a value that would identify a student. +// Add encrypted + blind-index pair instead. See: +// - apps/web/src/db/types/encrypted.ts (column types) +// - apps/web/src/lib/crypto/identity-cipher.ts (encrypt/decrypt/HMAC) +// - docs/architecture/multi-tenant-data-model.md (privacy model) + +// ---------- Enums ---------- + +export const courseRoleEnum = pgEnum("course_role", [ + "instructor", + "ta", + "student", + "observer", + "admin", +]); + +export const lmsProviderEnum = pgEnum("lms_provider", ["canvas"]); + +export const credentialProviderEnum = pgEnum("credential_provider", [ + "openai", + "anthropic", + "claude_for_education", + "openrouter", + "local", + "canvas", + "workos", +]); + +// ---------- Organizations ---------- +// Top-level tenant. 1:1 with a WorkOS Organization (auth tenant). +// Optionally bound to a Canvas Account / sub-account (data tenant). + +export const organizations = pgTable( + "organizations", + { + id: uuid("id").primaryKey().defaultRandom(), + slug: text("slug").notNull(), + name: text("name").notNull(), + workosOrganizationId: text("workos_organization_id").notNull(), + canvasAccountId: text("canvas_account_id"), + requiresFerpa: boolean("requires_ferpa").notNull().default(true), + requiresHipaa: boolean("requires_hipaa").notNull().default(false), + dataResidency: text("data_residency"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + uniqueIndex("organizations_slug_uq").on(t.slug), + uniqueIndex("organizations_workos_org_uq").on(t.workosOrganizationId), + ], +); + +// ---------- Users ---------- +// Global identity. workos_user_id is authoritative for non-pending users. +// Pending users are created from Canvas roster sync before first WorkOS login; +// they're reconciled by email_blind_index on first AuthKit login. +// +// PII fields (email, netid, display_name) are AES-256-GCM encrypted at rest. +// email_blind_index and netid_blind_index are HMAC-SHA256 over the normalized +// plaintext; they enable equality lookup without decryption (login +// reconciliation, "find user by netid" admin search, URL-with-netid routes). +// display_name has no blind index -- we never look users up by display name. + +export const users = pgTable( + "users", + { + id: uuid("id").primaryKey().defaultRandom(), + workosUserId: text("workos_user_id"), + email: encryptedText("email").notNull(), + emailBlindIndex: blindIndex("email_blind_index").notNull(), + netid: encryptedText("netid"), + netidBlindIndex: blindIndex("netid_blind_index"), + displayName: encryptedText("display_name"), + isPending: boolean("is_pending").notNull().default(false), + lastLoginAt: timestamp("last_login_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + uniqueIndex("users_email_blind_index_uq").on(t.emailBlindIndex), + uniqueIndex("users_workos_user_uq") + .on(t.workosUserId) + .where(sql`${t.workosUserId} IS NOT NULL`), + uniqueIndex("users_netid_blind_index_uq") + .on(t.netidBlindIndex) + .where(sql`${t.netidBlindIndex} IS NOT NULL`), + ], +); + +// ---------- Courses ---------- +// Projection of a Canvas course, scoped to one Organization. +// last_synced_at tracks freshness of the Canvas-derived fields. + +export const courses = pgTable( + "courses", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + canvasCourseId: text("canvas_course_id"), + code: text("code").notNull(), + term: text("term").notNull(), + title: text("title").notNull(), + isActive: boolean("is_active").notNull().default(true), + lastSyncedAt: timestamp("last_synced_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + index("courses_org_idx").on(t.organizationId), + uniqueIndex("courses_org_canvas_course_uq") + .on(t.organizationId, t.canvasCourseId) + .where(sql`${t.canvasCourseId} IS NOT NULL`), + ], +); + +// ---------- CourseMembership ---------- +// User <-> Course with a role. Replaces Django's Teacher/Student profiles. +// Projection of a Canvas enrollment when canvas_enrollment_id is present. + +export const courseMemberships = pgTable( + "course_memberships", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + courseId: uuid("course_id") + .notNull() + .references(() => courses.id, { onDelete: "cascade" }), + role: courseRoleEnum("role").notNull(), + canvasEnrollmentId: text("canvas_enrollment_id"), + canvasRole: text("canvas_role"), + enrolledAt: timestamp("enrolled_at", { withTimezone: true }) + .notNull() + .defaultNow(), + droppedAt: timestamp("dropped_at", { withTimezone: true }), + lastSyncedAt: timestamp("last_synced_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + uniqueIndex("course_memberships_user_course_uq").on(t.userId, t.courseId), + uniqueIndex("course_memberships_canvas_enrollment_uq") + .on(t.canvasEnrollmentId) + .where(sql`${t.canvasEnrollmentId} IS NOT NULL`), + index("course_memberships_course_idx").on(t.courseId), + ], +); + +// ---------- OrganizationCredential ---------- +// Per-org secrets pointer. secret_ref stores an external secrets-manager +// reference (Cloudflare Secrets Store, AWS Secrets Manager, etc.) -- never +// the secret itself. + +export const organizationCredentials = pgTable( + "organization_credentials", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + provider: credentialProviderEnum("provider").notNull(), + label: text("label").notNull(), + secretRef: text("secret_ref").notNull(), + rotatedAt: timestamp("rotated_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + index("organization_credentials_org_idx").on(t.organizationId), + uniqueIndex("organization_credentials_org_provider_label_uq").on( + t.organizationId, + t.provider, + t.label, + ), + ], +); + +// ---------- LMSIntegration ---------- +// Per-course LMS connection. At most one per course in the MVP. +// LTI 1.3 deployment metadata + a pointer to the Canvas API token credential. + +export const lmsIntegrations = pgTable( + "lms_integrations", + { + id: uuid("id").primaryKey().defaultRandom(), + courseId: uuid("course_id") + .notNull() + .references(() => courses.id, { onDelete: "cascade" }), + provider: lmsProviderEnum("provider").notNull().default("canvas"), + ltiIss: text("lti_iss").notNull(), + ltiClientId: text("lti_client_id").notNull(), + ltiDeploymentId: text("lti_deployment_id").notNull(), + apiCredentialId: uuid("api_credential_id").references( + () => organizationCredentials.id, + { onDelete: "set null" }, + ), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + uniqueIndex("lms_integrations_course_uq").on(t.courseId), + uniqueIndex("lms_integrations_iss_client_deployment_uq").on( + t.ltiIss, + t.ltiClientId, + t.ltiDeploymentId, + ), + ], +); + +// ---------- LTILaunch ---------- +// Append-only log of LTI 1.3 launches. nonce uniqueness guards against replay. +// user_id is nullable + ON DELETE SET NULL so the audit trail survives a +// user deletion (FERPA "right to be forgotten" leaves the launch row, sans PII). + +export const ltiLaunches = pgTable( + "lti_launches", + { + id: uuid("id").primaryKey().defaultRandom(), + lmsIntegrationId: uuid("lms_integration_id") + .notNull() + .references(() => lmsIntegrations.id, { onDelete: "cascade" }), + userId: uuid("user_id").references(() => users.id, { + onDelete: "set null", + }), + nonce: text("nonce").notNull(), + resourceLinkId: text("resource_link_id"), + roleClaim: text("role_claim"), + occurredAt: timestamp("occurred_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + uniqueIndex("lti_launches_nonce_uq").on(t.nonce), + index("lti_launches_integration_idx").on(t.lmsIntegrationId), + index("lti_launches_user_idx").on(t.userId), + ], +); + +// ---------- Relations ---------- + +export const organizationsRelations = relations(organizations, ({ many }) => ({ + courses: many(courses), + credentials: many(organizationCredentials), +})); + +export const usersRelations = relations(users, ({ many }) => ({ + memberships: many(courseMemberships), + ltiLaunches: many(ltiLaunches), +})); + +export const coursesRelations = relations(courses, ({ one, many }) => ({ + organization: one(organizations, { + fields: [courses.organizationId], + references: [organizations.id], + }), + memberships: many(courseMemberships), + lmsIntegration: one(lmsIntegrations), +})); + +export const courseMembershipsRelations = relations( + courseMemberships, + ({ one }) => ({ + user: one(users, { + fields: [courseMemberships.userId], + references: [users.id], + }), + course: one(courses, { + fields: [courseMemberships.courseId], + references: [courses.id], + }), + }), +); + +export const organizationCredentialsRelations = relations( + organizationCredentials, + ({ one, many }) => ({ + organization: one(organizations, { + fields: [organizationCredentials.organizationId], + references: [organizations.id], + }), + lmsIntegrations: many(lmsIntegrations), + }), +); + +export const lmsIntegrationsRelations = relations( + lmsIntegrations, + ({ one, many }) => ({ + course: one(courses, { + fields: [lmsIntegrations.courseId], + references: [courses.id], + }), + apiCredential: one(organizationCredentials, { + fields: [lmsIntegrations.apiCredentialId], + references: [organizationCredentials.id], + }), + launches: many(ltiLaunches), + }), +); + +export const ltiLaunchesRelations = relations(ltiLaunches, ({ one }) => ({ + lmsIntegration: one(lmsIntegrations, { + fields: [ltiLaunches.lmsIntegrationId], + references: [lmsIntegrations.id], + }), + user: one(users, { + fields: [ltiLaunches.userId], + references: [users.id], + }), +})); diff --git a/apps/web/src/db/schema/pings.ts b/apps/web/src/db/schema/pings.ts new file mode 100644 index 00000000..c472456f --- /dev/null +++ b/apps/web/src/db/schema/pings.ts @@ -0,0 +1,7 @@ +import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core"; + +export const pings = pgTable("pings", { + id: uuid("id").primaryKey().defaultRandom(), + message: text("message").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), +}); diff --git a/apps/web/src/db/types/encrypted.ts b/apps/web/src/db/types/encrypted.ts new file mode 100644 index 00000000..0dd59c86 --- /dev/null +++ b/apps/web/src/db/types/encrypted.ts @@ -0,0 +1,55 @@ +/** + * Branded byte-typed Drizzle column helpers for at-rest encryption of PII. + * + * Two column types are exposed: + * - encryptedText(name): AES-256-GCM ciphertext envelope, stored as bytea. + * - blindIndex(name): HMAC-SHA256 of the normalized plaintext, stored as + * bytea. Used as a deterministic equality-lookup token + * for encrypted fields. + * + * The branded TS types (Ciphertext, BlindIndex) prevent accidental mixing of + * encrypted bytes with plaintext strings, or of ciphertext bytes with blind + * indexes, at compile time. + * + * The actual encryption/HMAC happens in lib/crypto/identity-cipher.ts. The + * column types here only define the SQL shape and the driver <-> TS coercion; + * they do not perform crypto. + */ + +import { customType } from "drizzle-orm/pg-core"; + +declare const CiphertextBrand: unique symbol; +declare const BlindIndexBrand: unique symbol; + +export type Ciphertext = Uint8Array & { readonly [CiphertextBrand]: true }; +export type BlindIndex = Uint8Array & { readonly [BlindIndexBrand]: true }; + +export const encryptedText = customType<{ + data: Ciphertext; + driverData: Buffer; +}>({ + dataType() { + return "bytea"; + }, + toDriver(value): Buffer { + return Buffer.from(value); + }, + fromDriver(value): Ciphertext { + return new Uint8Array(value) as Ciphertext; + }, +}); + +export const blindIndex = customType<{ + data: BlindIndex; + driverData: Buffer; +}>({ + dataType() { + return "bytea"; + }, + toDriver(value): Buffer { + return Buffer.from(value); + }, + fromDriver(value): BlindIndex { + return new Uint8Array(value) as BlindIndex; + }, +}); diff --git a/apps/web/src/lib/ai.ts b/apps/web/src/lib/ai.ts new file mode 100644 index 00000000..01fc49d5 --- /dev/null +++ b/apps/web/src/lib/ai.ts @@ -0,0 +1,12 @@ +import { createOpenAI } from "@ai-sdk/openai"; + +export function getOpenRouter(apiKey: string) { + return createOpenAI({ + apiKey, + baseURL: "https://openrouter.ai/api/v1", + headers: { + "HTTP-Referer": "https://llteacher.uw.edu", + "X-Title": "LLteacher", + }, + }); +} diff --git a/apps/web/src/lib/crypto/identity-cipher.test.ts b/apps/web/src/lib/crypto/identity-cipher.test.ts new file mode 100644 index 00000000..c3060bd3 --- /dev/null +++ b/apps/web/src/lib/crypto/identity-cipher.test.ts @@ -0,0 +1,203 @@ +import { beforeAll, describe, expect, it } from "vitest"; + +import type { Ciphertext } from "../../db/types/encrypted"; + +import { IdentityCipher, type IdentityCipherKeys } from "./identity-cipher"; + +let keys: IdentityCipherKeys; + +beforeAll(async () => { + const encryptionKey = (await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"], + )) as CryptoKey; + const blindIndexKey = (await crypto.subtle.generateKey( + { name: "HMAC", hash: "SHA-256" }, + true, + ["sign"], + )) as CryptoKey; + keys = { + encryptionKey, + blindIndexKey, + encryptionKeyId: "k1", + }; +}); + +describe("IdentityCipher.encryptString / decryptString", () => { + it("round-trips an ASCII string", async () => { + const cipher = new IdentityCipher(keys); + const ct = await cipher.encryptString("cdcore@uw.edu"); + expect(await cipher.decryptString(ct)).toBe("cdcore@uw.edu"); + }); + + it("round-trips an empty string", async () => { + const cipher = new IdentityCipher(keys); + const ct = await cipher.encryptString(""); + expect(await cipher.decryptString(ct)).toBe(""); + }); + + it("round-trips unicode", async () => { + const cipher = new IdentityCipher(keys); + const plaintext = "学生 — naïve résumé 🎓"; + const ct = await cipher.encryptString(plaintext); + expect(await cipher.decryptString(ct)).toBe(plaintext); + }); + + it("round-trips a long string", async () => { + const cipher = new IdentityCipher(keys); + const plaintext = "x".repeat(10_000); + const ct = await cipher.encryptString(plaintext); + expect(await cipher.decryptString(ct)).toBe(plaintext); + }); + + it("produces different ciphertexts for the same plaintext (random IV)", async () => { + const cipher = new IdentityCipher(keys); + const plaintext = "cdcore@uw.edu"; + const ct1 = await cipher.encryptString(plaintext); + const ct2 = await cipher.encryptString(plaintext); + expect(Buffer.from(ct1).equals(Buffer.from(ct2))).toBe(false); + expect(await cipher.decryptString(ct1)).toBe(plaintext); + expect(await cipher.decryptString(ct2)).toBe(plaintext); + }); + + it("decrypts ciphertext produced by a sibling cipher with the same keys", async () => { + const writer = new IdentityCipher(keys); + const reader = new IdentityCipher(keys); + const ct = await writer.encryptString("cdcore@uw.edu"); + expect(await reader.decryptString(ct)).toBe("cdcore@uw.edu"); + }); + + it("rejects ciphertext with an unknown key id", async () => { + const cipher = new IdentityCipher(keys); + const ct = await cipher.encryptString("cdcore@uw.edu"); + ct[1] = ct[1] ^ 0xff; // flip a bit in the key id field + await expect(cipher.decryptString(ct)).rejects.toThrow(/unknown key id/i); + }); + + it("rejects ciphertext with an unsupported version byte", async () => { + const cipher = new IdentityCipher(keys); + const ct = await cipher.encryptString("cdcore@uw.edu"); + ct[0] = 0xff; + await expect(cipher.decryptString(ct)).rejects.toThrow(/version/i); + }); + + it("rejects ciphertext shorter than the envelope header", async () => { + const cipher = new IdentityCipher(keys); + const truncated = new Uint8Array(10) as Ciphertext; + await expect(cipher.decryptString(truncated)).rejects.toThrow( + /shorter than envelope/i, + ); + }); + + it("rejects ciphertext whose AES-GCM auth tag has been tampered with", async () => { + const cipher = new IdentityCipher(keys); + const ct = await cipher.encryptString("cdcore@uw.edu"); + ct[ct.length - 1] = ct[ct.length - 1] ^ 0xff; + await expect(cipher.decryptString(ct)).rejects.toThrow(); + }); + + it("rejects ciphertext when decrypted with a different encryption key but same key id", async () => { + const otherEncryptionKey = (await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"], + )) as CryptoKey; + const writer = new IdentityCipher(keys); + const reader = new IdentityCipher({ + ...keys, + encryptionKey: otherEncryptionKey, + }); + const ct = await writer.encryptString("cdcore@uw.edu"); + await expect(reader.decryptString(ct)).rejects.toThrow(); + }); +}); + +describe("IdentityCipher.computeBlindIndex", () => { + it("is deterministic for the same input", async () => { + const cipher = new IdentityCipher(keys); + const a = await cipher.computeBlindIndex("cdcore"); + const b = await cipher.computeBlindIndex("cdcore"); + expect(Buffer.from(a).equals(Buffer.from(b))).toBe(true); + }); + + it("produces different output for different inputs", async () => { + const cipher = new IdentityCipher(keys); + const a = await cipher.computeBlindIndex("cdcore"); + const b = await cipher.computeBlindIndex("alice"); + expect(Buffer.from(a).equals(Buffer.from(b))).toBe(false); + }); + + it("uses the blind-index key (separation from encryption key)", async () => { + const otherBlindKey = (await crypto.subtle.generateKey( + { name: "HMAC", hash: "SHA-256" }, + true, + ["sign"], + )) as CryptoKey; + const cipherA = new IdentityCipher(keys); + const cipherB = new IdentityCipher({ + ...keys, + blindIndexKey: otherBlindKey, + }); + const a = await cipherA.computeBlindIndex("cdcore"); + const b = await cipherB.computeBlindIndex("cdcore"); + expect(Buffer.from(a).equals(Buffer.from(b))).toBe(false); + }); + + it("produces a 32-byte output (HMAC-SHA256)", async () => { + const cipher = new IdentityCipher(keys); + const out = await cipher.computeBlindIndex("anything"); + expect(out.byteLength).toBe(32); + }); +}); + +describe("IdentityCipher normalizers", () => { + it("normalizeEmail trims and lowercases", () => { + expect(IdentityCipher.normalizeEmail(" CDCore@UW.edu ")).toBe( + "cdcore@uw.edu", + ); + }); + + it("normalizeNetid trims and lowercases", () => { + expect(IdentityCipher.normalizeNetid(" CDCore ")).toBe("cdcore"); + }); + + it("blind index over normalized values is stable across casings and whitespace", async () => { + const cipher = new IdentityCipher(keys); + const a = await cipher.computeBlindIndex( + IdentityCipher.normalizeEmail("CDCore@UW.edu"), + ); + const b = await cipher.computeBlindIndex( + IdentityCipher.normalizeEmail(" cdcore@uw.edu "), + ); + expect(Buffer.from(a).equals(Buffer.from(b))).toBe(true); + }); +}); + +describe("IdentityCipher constructor", () => { + it("rejects an empty key id", () => { + expect( + () => new IdentityCipher({ ...keys, encryptionKeyId: "" }), + ).toThrow(/encryptionKeyId/); + }); + + it("rejects an oversized key id (>16 bytes)", () => { + expect( + () => + new IdentityCipher({ + ...keys, + encryptionKeyId: "x".repeat(17), + }), + ).toThrow(/encryptionKeyId/); + }); + + it("accepts a 16-byte key id at the boundary", () => { + expect( + () => + new IdentityCipher({ + ...keys, + encryptionKeyId: "x".repeat(16), + }), + ).not.toThrow(); + }); +}); diff --git a/apps/web/src/lib/crypto/identity-cipher.ts b/apps/web/src/lib/crypto/identity-cipher.ts new file mode 100644 index 00000000..616bfadf --- /dev/null +++ b/apps/web/src/lib/crypto/identity-cipher.ts @@ -0,0 +1,139 @@ +/** + * Identity cipher: AES-256-GCM encryption + HMAC-SHA256 blind indexes for PII + * stored in the application database. + * + * Threat model: + * - Defends against pure database exfiltration (Neon compromise, leaked + * SQL dump). An attacker with only the DB sees ciphertext and blind + * indexes -- not names, emails, or NetIDs. + * - Does NOT defend against compromise of the Worker runtime, which has the + * decryption key resident in memory for the lifetime of the isolate. + * - Does NOT change FERPA/GDPR legal status: encrypted PII is still PII + * because we hold the keys. See docs/architecture/multi-tenant-data-model.md. + * + * Keys (loaded once at Worker startup from Cloudflare Secrets Store): + * - encryptionKey: AES-256-GCM. Encrypts and decrypts PII fields. + * - blindIndexKey: HMAC-SHA256. Computes deterministic lookup tokens for + * encrypted fields. MUST be a different key from encryptionKey -- reusing + * the same key materially weakens both primitives. + * + * Ciphertext envelope (binary layout): + * [1 byte version=0x01][16 bytes keyId][12 bytes IV][N bytes AES-GCM output] + * + * The keyId is written into every ciphertext so the encryption key can be + * rotated without re-encrypting existing rows. v0 ships with a single active + * key; rotation tooling lands when we actually need to rotate. + * + * Blind indexes do not embed a key id -- rotating the blind index key requires + * re-hashing every row that uses it. Plan for that explicitly when it happens. + */ + +import type { BlindIndex, Ciphertext } from "../../db/types/encrypted"; + +const ENVELOPE_VERSION = 0x01; +const KEY_ID_BYTES = 16; +const IV_BYTES = 12; +const HEADER_BYTES = 1 + KEY_ID_BYTES + IV_BYTES; + +export interface IdentityCipherKeys { + encryptionKey: CryptoKey; + blindIndexKey: CryptoKey; + /** Up to 16 ASCII bytes; e.g. "k1" or "2026-06". */ + encryptionKeyId: string; +} + +export class IdentityCipher { + constructor(private readonly keys: IdentityCipherKeys) { + const idBytes = new TextEncoder().encode(keys.encryptionKeyId); + if (idBytes.byteLength === 0 || idBytes.byteLength > KEY_ID_BYTES) { + throw new Error( + `encryptionKeyId must be 1..${KEY_ID_BYTES} bytes (got ${idBytes.byteLength})`, + ); + } + } + + async encryptString(plaintext: string): Promise { + const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES)); + const data = new TextEncoder().encode(plaintext); + const payload = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + this.keys.encryptionKey, + data, + ); + return wrap(this.keys.encryptionKeyId, iv, new Uint8Array(payload)); + } + + async decryptString(ciphertext: Ciphertext): Promise { + const { iv, payload, keyId } = unwrap(ciphertext); + if (keyId !== this.keys.encryptionKeyId) { + // v0: single active key. When rotation lands, look up the right key here. + throw new Error(`Cannot decrypt: unknown key id "${keyId}"`); + } + const plaintext = await crypto.subtle.decrypt( + { name: "AES-GCM", iv }, + this.keys.encryptionKey, + payload, + ); + return new TextDecoder().decode(plaintext); + } + + async computeBlindIndex(normalized: string): Promise { + const data = new TextEncoder().encode(normalized); + const sig = await crypto.subtle.sign("HMAC", this.keys.blindIndexKey, data); + return new Uint8Array(sig) as BlindIndex; + } + + static normalizeEmail(email: string): string { + return email.trim().toLowerCase(); + } + + static normalizeNetid(netid: string): string { + return netid.trim().toLowerCase(); + } +} + +function wrap( + keyId: string, + iv: Uint8Array, + payload: Uint8Array, +): Ciphertext { + const out = new Uint8Array(HEADER_BYTES + payload.byteLength); + out[0] = ENVELOPE_VERSION; + const keyIdBytes = new TextEncoder().encode(keyId); + out.set(keyIdBytes, 1); // remaining bytes are zero-padded + out.set(iv, 1 + KEY_ID_BYTES); + out.set(payload, HEADER_BYTES); + return out as Ciphertext; +} + +function unwrap(ciphertext: Ciphertext): { + iv: Uint8Array; + payload: Uint8Array; + keyId: string; +} { + if (ciphertext.length < HEADER_BYTES) { + throw new Error("Ciphertext shorter than envelope header"); + } + if (ciphertext[0] !== ENVELOPE_VERSION) { + throw new Error(`Unsupported ciphertext version: ${ciphertext[0]}`); + } + // Allocate fresh ArrayBuffer-backed Uint8Arrays so the result satisfies + // BufferSource (WebCrypto requires Uint8Array; subarray/slice + // on Ciphertext propagates ArrayBufferLike, which doesn't satisfy it). + const keyIdRaw = new Uint8Array(KEY_ID_BYTES); + keyIdRaw.set(ciphertext.subarray(1, 1 + KEY_ID_BYTES)); + let keyIdEnd = keyIdRaw.length; + while (keyIdEnd > 0 && keyIdRaw[keyIdEnd - 1] === 0) { + keyIdEnd--; + } + const keyId = new TextDecoder().decode(keyIdRaw.subarray(0, keyIdEnd)); + + const iv = new Uint8Array(IV_BYTES); + iv.set(ciphertext.subarray(1 + KEY_ID_BYTES, HEADER_BYTES)); + + const payloadLen = ciphertext.length - HEADER_BYTES; + const payload = new Uint8Array(payloadLen); + payload.set(ciphertext.subarray(HEADER_BYTES)); + + return { iv, payload, keyId }; +} diff --git a/apps/web/src/lib/workos.ts b/apps/web/src/lib/workos.ts new file mode 100644 index 00000000..4c9d2595 --- /dev/null +++ b/apps/web/src/lib/workos.ts @@ -0,0 +1,10 @@ +import { WorkOS } from "@workos-inc/node"; + +let cached: WorkOS | null = null; + +export function getWorkOS(apiKey: string): WorkOS { + if (!cached) { + cached = new WorkOS(apiKey); + } + return cached; +} diff --git a/apps/web/src/server/index.ts b/apps/web/src/server/index.ts new file mode 100644 index 00000000..3ef6ada6 --- /dev/null +++ b/apps/web/src/server/index.ts @@ -0,0 +1,19 @@ +import { Hono } from "hono"; +import { helloHandler } from "./routes/hello"; +import { chatHandler } from "./routes/chat"; + +const app = new Hono<{ Bindings: Env }>(); + +// API routes — registered directly on `app` rather than via app.route(prefix, sub) +// to avoid Hono's prefix-stripping behavior that can cause /api/hello to not +// match a sub-app's `/` handler. +app.get("/api/hello", helloHandler); +app.post("/api/chat", chatHandler); + +// Everything else: delegate to the static asset binding. +// In dev, this proxies to Vite's pipeline (so HMR + source maps work). +// In prod, it serves built assets, falling back to index.html for SPA routes +// per the `not_found_handling: "single-page-application"` setting in wrangler.jsonc. +app.all("*", (c) => c.env.ASSETS.fetch(c.req.raw)); + +export default app; diff --git a/apps/web/src/server/routes/chat.ts b/apps/web/src/server/routes/chat.ts new file mode 100644 index 00000000..47cfc1a9 --- /dev/null +++ b/apps/web/src/server/routes/chat.ts @@ -0,0 +1,107 @@ +/* -------------------------------------------------------------------------- + POST /api/chat — Vercel AI SDK chat endpoint. + + Receives the client's UIMessage history, converts to model messages, + calls OpenRouter via streamText with one display tool (showDefinition), + and streams the response back in the UI message stream format that the + client's useChat hook understands. + + The model can produce either: + · plain markdown text — rendered as paragraphs in the AI message + · showDefinition tool — rendered as a component + + This is the minimum-viable Generative UI loop. Adding more tools is a + matter of: define the Zod schema here + ship a renderer in packages/ui. + -------------------------------------------------------------------------- */ + +import type { Context } from "hono"; +import { + streamText, + convertToModelMessages, + jsonSchema, + stepCountIs, + type UIMessage, + type ToolSet, +} from "ai"; +import { getOpenRouter } from "../../lib/ai"; + +const SYSTEM_PROMPT = `You are an AI tutor for an introductory statistics course at the University of Washington. Your job is to guide students through homework problems using the Socratic method: ask leading questions, build intuition step by step, never just dump the answer. + +You have one structured rendering tool available: showDefinition. Call it whenever you are formally introducing a named statistical concept ("p-value", "null hypothesis", "standard error", "confidence interval", "type I error", etc.) — give the student a polished definition card with the term and a 1–2 sentence plain-language body. For everything else (guiding questions, follow-ups, gentle nudges, walking through computations), reply in plain markdown — no tool call. + +Be warm, curious, and patient. Prefer questions over assertions.`; + +/* Tool catalog typed as ToolSet. We use the AI SDK's jsonSchema() helper + instead of Zod here — Zod's deeply parameterized types collide with the + ToolSet generic inference (TS2589). + + Display tools (like showDefinition) render args on the client via the + registry in @llteacher/ui/generative. They still ship a server-side + `execute` that returns a sentinel: without a tool result the conversation + history becomes invalid the moment the user sends a second message (the + model sees an assistant message with an unanswered tool call and either + refuses or emits nothing). The sentinel also lets the model continue with + follow-up text in the same turn via stopWhen below. */ +const TOOLS: ToolSet = { + showDefinition: { + description: + "Render a formal definition card for a named statistical concept. " + + "Use when introducing a term by name (e.g., 'p-value', 'standard error'). " + + "Keep body to 1-2 sentences in plain language. " + + "Args: term (the concept name); body (the plain-language definition).", + inputSchema: jsonSchema<{ term: string; body: string }>({ + type: "object", + properties: { + term: { + type: "string", + description: "The term being defined, e.g. 'p-value'", + }, + body: { + type: "string", + description: "Plain-language definition in 1-2 sentences", + }, + }, + required: ["term", "body"], + additionalProperties: false, + }), + execute: async ({ term }: { term: string; body: string }) => ({ + status: "displayed" as const, + term, + }), + }, +}; + +export async function chatHandler(c: Context<{ Bindings: Env }>) { + const apiKey = c.env.OPENROUTER_API_KEY; + if (!apiKey) { + return c.json( + { + error: + "OPENROUTER_API_KEY is not set. Add it to apps/web/.dev.vars for local dev or via `wrangler secret put OPENROUTER_API_KEY` for prod.", + }, + 500, + ); + } + + const { messages } = await c.req.json<{ messages: UIMessage[] }>(); + + const openrouter = getOpenRouter(apiKey); + + const result = streamText({ + /* Gemma 4 31B (instruction-tuned) on OpenRouter's free tier. + Released 2026-04-02, 262K context, native function calling (custom + XML format that OpenRouter normalizes to the OpenAI-compatible tool + call shape the AI SDK expects). Strong on reasoning + Socratic-style + instruction following per Google's docs. Free, with rate limits. */ + model: openrouter("google/gemma-4-31b-it:free"), + system: SYSTEM_PROMPT, + messages: convertToModelMessages(messages), + tools: TOOLS, + /* Allow up to 5 steps so the model can call a display tool and then + continue with the follow-up Socratic question in the same turn. + Without this, streamText stops the moment a tool call is emitted. */ + stopWhen: stepCountIs(5), + }); + + return result.toUIMessageStreamResponse(); +} diff --git a/apps/web/src/server/routes/hello.test.ts b/apps/web/src/server/routes/hello.test.ts new file mode 100644 index 00000000..d98343fa --- /dev/null +++ b/apps/web/src/server/routes/hello.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, vi } from "vitest"; +import { hello } from "./hello"; + +vi.mock("../../db/client", () => ({ + makeDb: () => ({ + insert: () => ({ + values: () => ({ + returning: async () => [{ id: "00000000-0000-0000-0000-000000000001", message: "mocked" }], + }), + }), + }), +})); + +describe("GET /api/hello", () => { + it("returns a HelloResponse with mocked message and ping_id", async () => { + const res = await hello.request("/", {}, { DATABASE_URL: "ignored" } as Env); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ + message: "mocked", + ping_id: "00000000-0000-0000-0000-000000000001", + }); + }); + + it("returns a stub HelloResponse when DATABASE_URL is empty", async () => { + const res = await hello.request("/", {}, { DATABASE_URL: "" } as Env); + expect(res.status).toBe(200); + const body = (await res.json()) as { message: string; ping_id: string }; + expect(body.message).toContain("stub"); + expect(body.ping_id).toMatch(/^[0-9a-f-]{36}$/); + }); +}); diff --git a/apps/web/src/server/routes/hello.ts b/apps/web/src/server/routes/hello.ts new file mode 100644 index 00000000..54586ea4 --- /dev/null +++ b/apps/web/src/server/routes/hello.ts @@ -0,0 +1,33 @@ +import { Hono, type Context } from "hono"; +import { makeDb } from "../../db/client"; +import { pings } from "../../db/schema"; +import type { HelloResponse } from "../../shared/types"; + +export async function helloHandler(c: Context<{ Bindings: Env }>) { + // Dev fallback: when no DATABASE_URL is configured, return a stub so the + // React app renders end-to-end without provisioning Neon. The real Drizzle + // path takes over the moment DATABASE_URL is set in web/.dev.vars. + if (!c.env.DATABASE_URL) { + const resp: HelloResponse = { + message: "Hono Worker is alive. (stub — set DATABASE_URL to hit Neon)", + ping_id: crypto.randomUUID(), + }; + return c.json(resp); + } + + const db = makeDb(c.env.DATABASE_URL); + const [row] = await db + .insert(pings) + .values({ message: "Hello from Hono + Drizzle + Neon." }) + .returning(); + const resp: HelloResponse = { + message: row.message, + ping_id: row.id, + }; + return c.json(resp); +} + +// Sub-app preserved for direct unit testing; production routing happens via +// app.get("/api/hello", helloHandler) in server/index.ts. +export const hello = new Hono<{ Bindings: Env }>(); +hello.get("/", helloHandler); diff --git a/apps/web/src/shared/types.ts b/apps/web/src/shared/types.ts new file mode 100644 index 00000000..4ae45dcf --- /dev/null +++ b/apps/web/src/shared/types.ts @@ -0,0 +1,17 @@ +export type HelloResponse = { + message: string; + ping_id: string; +}; + +// Cloudflare Worker bindings + secrets. Augmented in Phase 1+. +declare global { + interface Env { + DATABASE_URL: string; + WORKOS_API_KEY: string; + WORKOS_CLIENT_ID: string; + OPENROUTER_API_KEY: string; + ASSETS: Fetcher; + } +} + +export {}; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 00000000..67681545 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "types": ["vite/client"] + }, + "include": ["src/client"], + "references": [ + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.worker.json" } + ] +} diff --git a/apps/web/tsconfig.node.json b/apps/web/tsconfig.node.json new file mode 100644 index 00000000..04ccc488 --- /dev/null +++ b/apps/web/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts", "drizzle.config.ts", "vitest.config.ts"] +} diff --git a/apps/web/tsconfig.worker.json b/apps/web/tsconfig.worker.json new file mode 100644 index 00000000..f8837b0d --- /dev/null +++ b/apps/web/tsconfig.worker.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "emitDeclarationOnly": true, + "outDir": "./dist/.tsbuildinfo-worker", + "types": ["@cloudflare/workers-types"], + "lib": ["ES2022", "WebWorker"] + }, + "include": ["src/server", "src/shared", "src/db", "src/lib"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 00000000..489a0b4c --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,140 @@ +import { defineConfig, type Plugin } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; +import { cloudflare } from "@cloudflare/vite-plugin"; +import { existsSync, readFileSync } from "node:fs"; +import { Readable } from "node:stream"; +import path from "node:path"; + +/* -------------------------------------------------------------------------- + Dev API proxy — runs the actual Hono Worker code in Vite's Node process + for /api/* requests. + + Why: @cloudflare/vite-plugin's Worker routing isn't reliably wiring + /api/* to the Worker in dev mode (returns 404). This proxy loads the + Hono app via Vite's SSR module loader, parses .dev.vars manually for + env bindings, converts Node's IncomingMessage <-> Web Request/Response, + and streams the result back. Production deploys are unaffected — they + run the real Worker on Cloudflare. + + Remove this plugin once the Cloudflare Vite plugin handles /api/* + routing end-to-end in dev. + -------------------------------------------------------------------------- */ + +function loadDevVars(rootDir: string): Record { + const devVarsPath = path.resolve(rootDir, ".dev.vars"); + if (!existsSync(devVarsPath)) return {}; + const content = readFileSync(devVarsPath, "utf-8"); + const env: Record = {}; + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq < 0) continue; + const key = trimmed.slice(0, eq).trim(); + let value = trimmed.slice(eq + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + env[key] = value; + } + return env; +} + +const devApiProxy: Plugin = { + name: "llteacher-dev-api-proxy", + apply: "serve", + configureServer(server) { + /* Resolve .dev.vars relative to the workspace root (the dir containing + package.json / wrangler.jsonc), not Vite's `root` (src/client/). */ + const workspaceRoot = path.resolve(import.meta.dirname); + + server.middlewares.use(async (req, res, next) => { + if (!req.url?.startsWith("/api/")) return next(); + + try { + const env = { + ...loadDevVars(workspaceRoot), + /* Stub ASSETS binding so the Worker's `app.all("*")` catch-all + doesn't blow up if a request misses every /api/* route. */ + ASSETS: { + fetch: async () => + new Response("Not Found (dev API proxy)", { status: 404 }), + }, + }; + + /* Build a Web Request from the Node IncomingMessage. */ + const url = `http://${req.headers.host ?? "localhost"}${req.url}`; + const method = req.method ?? "GET"; + const headers: Record = {}; + for (const [k, v] of Object.entries(req.headers)) { + if (typeof v === "string") headers[k] = v; + } + + let body: ReadableStream | undefined; + if (method !== "GET" && method !== "HEAD") { + body = Readable.toWeb(req) as ReadableStream; + } + + const request = new Request(url, { + method, + headers, + body, + /* `duplex: 'half'` is required by fetch spec when body is a stream */ + ...(body ? { duplex: "half" } : {}), + } as RequestInit & { duplex?: string }); + + /* Load the Hono app via Vite SSR. The relative path is resolved + against the Vite `root` (src/client/), so we use ../server/index.ts. */ + const mod = await server.ssrLoadModule("../server/index.ts"); + const app = mod.default; + + const response: Response = await app.fetch(request, env); + + res.statusCode = response.status; + response.headers.forEach((value, key) => { + res.setHeader(key, value); + }); + + if (!response.body) { + res.end(); + return; + } + + const reader = response.body.getReader(); + while (true) { + const { value, done } = await reader.read(); + if (done) break; + res.write(Buffer.from(value)); + } + res.end(); + } catch (err) { + // eslint-disable-next-line no-console + console.error("[dev-api-proxy] error:", err); + res.statusCode = 500; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: String(err) })); + } + }); + }, +}; + +export default defineConfig({ + root: "src/client", + server: { + port: 2311, + strictPort: true, + }, + preview: { + port: 2311, + strictPort: true, + }, + build: { + outDir: "../../dist/client", + emptyOutDir: true, + }, + plugins: [devApiProxy, react(), tailwindcss(), cloudflare()], +}); diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 00000000..840e944d --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + }, +}); diff --git a/apps/web/wrangler.jsonc b/apps/web/wrangler.jsonc new file mode 100644 index 00000000..f51018d4 --- /dev/null +++ b/apps/web/wrangler.jsonc @@ -0,0 +1,19 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "llteacher-web", + "main": "src/server/index.ts", + "compatibility_date": "2025-09-01", + "compatibility_flags": ["nodejs_compat"], + "assets": { + "directory": "./dist/client", + "binding": "ASSETS", + "not_found_handling": "single-page-application", + "run_worker_first": true + }, + "observability": { + "enabled": true + }, + "vars": { + // Non-secret vars go here. Secrets live in .dev.vars locally and Wrangler secrets in prod. + } +} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..3ade1041 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,34 @@ +# LLTeacher v2 — Documentation + +Documentation for the LLTeacher v2 port and platform-generalization work. Source-of-truth implementation lives in the codebase; these docs cover the architecture decisions, design system, and active plans. + +## Sections + +| Section | Contents | +|---|---| +| [`architecture/`](./architecture/README.md) | Cross-cutting architectural concerns: Generative UI loop, dev API proxy, integration patterns | +| [`design-system/`](./design-system/README.md) | v2 design system: principles, tokens, components, aesthetic spec | +| [`superpowers/plans/`](./superpowers/plans/) | Implementation plans — dated `YYYY-MM-DD-.md`, executed task-by-task | + +## Where things live in code + +``` +apps/ + web/ Student-facing React app (Vite, Tailwind 4, useChat) + src/client/ React 19 client, App.tsx + components + src/server/ Hono Worker, routes (chat, hello), lib (ai) + vite.config.ts Vite config + dev API proxy + admin/ Instructor-facing app (same stack) +packages/ + ui/ Shared design system: components, generative UI renderers, styles.css +docs/ + architecture/ This section — cross-cutting docs + design-system/ Design system reference + superpowers/plans/ Implementation plans +``` + +## Conventions + +- **Plans** are dated `YYYY-MM-DD-.md` and live under `superpowers/plans/`. They are executed task-by-task via the subagent-driven-development workflow. +- **Architecture docs** describe how something works *today*. They are not aspirational — when behavior changes, the doc gets updated. +- **Design system docs** describe component contracts and visual spec. New components and new tools both get an entry. diff --git a/docs/architecture/README.md b/docs/architecture/README.md new file mode 100644 index 00000000..5dbec4cc --- /dev/null +++ b/docs/architecture/README.md @@ -0,0 +1,11 @@ +# Architecture + +Documentation for cross-cutting architectural concerns in the LLTeacher v2 port. Component-level design specs live in [`../design-system/`](../design-system/README.md); implementation plans live in [`../superpowers/plans/`](../superpowers/plans/). + +## Documents + +| Document | Contents | +|---|---| +| [generative-ui.md](./generative-ui.md) | End-to-end Generative UI loop: chat route, tool registry, DefinitionCard, recipe for adding new tools | +| [dev-api-proxy.md](./dev-api-proxy.md) | Why the Vite dev API proxy exists, how it routes `/api/*` to the Hono Worker in dev, and when to remove it | +| [admin-console.md](./admin-console.md) | Instructor admin app: editorial catalog aesthetic, view navigation, fixture data shapes mapped to Django models, sidebar collapse, TopNav `admin` mode, and the gitignore `lib/` footgun | diff --git a/docs/architecture/admin-console.md b/docs/architecture/admin-console.md new file mode 100644 index 00000000..56421d6f --- /dev/null +++ b/docs/architecture/admin-console.md @@ -0,0 +1,241 @@ +# Admin Console Architecture + +The instructor-facing surface of LLTeacher v2. A separate Vite app that ships on port 2312 in dev, shares the design system in `packages/ui` with the student app, and replaces the student's chat-and-paper layout with a cataloged record vocabulary suited to course authoring and submission review. + +Lives at `apps/admin/`. Student app architecture is at [generative-ui.md](./generative-ui.md); design system reference is at [../design-system/components.md](../design-system/components.md). + +## Aesthetic direction — editorial catalog console + +The student web app is a quiet reading room: warm paper, minimal chat, sidebar-as-syllabus. The admin is a **catalog of teaching artifacts**: homework records, LLM config records, submission rows, all anchored by typed catalog IDs (`HW·003`, `CFG·001`). + +| Concern | Student app | Admin app | +|---|---|---| +| Layout vocabulary | Chat + paper | Cataloged records + dense tables | +| Sidebar | Homework syllabus progress | Admin sections (Homeworks, Submissions, LLM Configs, Students) | +| Centerpiece | Conversation | Record list with drill-in detail views | +| Breadcrumb | Names current section | Names "Instructor Console · {view}" | +| AI marker color | Heritage Gold | Reused for record IDs + default-config marker | +| Mode signal | None | Heritage Gold dot in the affiliation tag | + +Same UW Husky Purple chrome, same Heritage Gold accent, same Geist Sans, same paper background. The brand is one product; the layout vocabulary diverges to match the instructor's task. + +### The signature element — RecordId + +Every cataloged artifact renders with a small Heritage Gold mono badge: `HW·003`, `CFG·001`. Middle-dot `·` separator, zero-padded 3-digit index, Heritage Gold border + faint Heritage Gold wash. The component is `apps/admin/src/client/components/RecordId.tsx`. The catalog metaphor justifies the dense tabular layout — every row has an ID stamp like a museum specimen. + +```tsx + // → HW·003 + // → CFG·001 (smaller, for inline use in eyebrows) +``` + +Prefix is constrained to `"HW" | "CFG" | "STU" | "SEC"` so the catalog vocabulary stays tight. Add a new prefix to the union when a new record type warrants it. + +## File layout + +``` +apps/admin/ +├── package.json name: llteacher-admin, port: 2312 +├── vite.config.ts standard Vite + React + Tailwind 4 config +└── src/client/ + ├── App.tsx Shell + tagged-union view state + localStorage + ├── main.tsx ReactDOM bootstrap + ├── lib/ + │ └── fixtures.ts Data shapes mirroring Django models + fixture data + ├── components/ + │ ├── AdminSidebar.tsx 4-item primary nav, collapsible, quick actions + │ ├── RecordId.tsx The signature catalog badge + │ ├── StatusBadge.tsx Mono small-caps badge w/ semantic dot + │ └── PageHeader.tsx Eyebrow + title + actions strip + └── views/ + ├── HomeworksView.tsx Primary landing + ├── SubmissionsView.tsx Per-homework student dashboard + └── LLMConfigsView.tsx Tutor configuration catalog +``` + +CSS for every admin-namespaced class lives in `packages/ui/styles.css` under the `ADMIN CONSOLE` block, prefixed `.admin-*`. The decision to keep admin styles in the shared package (rather than admin-local) means a future migration of these components into `packages/ui` is a TypeScript move only — no CSS migration. + +## View navigation + +No router dep. Type-safe tagged-union view state in `useState`, where `View` is a discriminated union with a `kind` discriminator and per-variant payload: + +```ts +type View = + | { kind: "homeworks" } + | { kind: "submissions"; homeworkId: string } + | { kind: "llm-configs" } + | { kind: "students" }; +``` + +Adding a view = adding a case to `View` + a branch in `App.tsx`'s render. When the view space grows past ~8 routes or shareable deep links become a requirement, swap to React Router — the component shape (a `navigate` callback + an `active` key) is already router-compatible. + +```mermaid +graph TD + A[Homeworks list] -->|click row| B[Submissions for that HW] + A -->|sidebar: LLM configs| C[LLM Configs list] + A -->|sidebar: Students| D[Students stub] + B -->|back button| A + C -->|sidebar: Homeworks| A + D -->|sidebar: Homeworks| A + B -->|sidebar: Homeworks| A + B -->|sidebar: LLM configs| C +``` + +The sidebar navigates from anywhere to any top-level section. The submissions view is a drill-in from a homework row and has a back affordance to the homework list. Other drill-ins (homework detail, LLM config detail) follow the same pattern when built. + +## Data fixtures — Django model mapping + +`apps/admin/src/client/lib/fixtures.ts` defines TypeScript types that are 1:1 with the Django models in `apps/{homeworks,llm,accounts,conversations}/src/models.py`. The fixtures are placeholder data; the *types* are the contract that real Drizzle queries will satisfy in Phase 1. + +| TS type | Django model | Fields preserved | +|---|---|---| +| `Teacher` | `accounts.models.Teacher` | id, name, email | +| `Student` | `accounts.models.Student` | id, name, initials (display), email | +| `LLMConfig` | `llm.models.LLMConfig` | id, recordNumber (display), name, modelName, basePromptPreview, temperature, maxCompletionTokens, isDefault, isActive, createdAt | +| `SectionSummary` | `homeworks.models.Section` | id, homeworkId, title, order, hasSolution, submissionsCount (aggregated) | +| `Homework` | `homeworks.models.Homework` | id, recordNumber (display), title, description, dueDate, llmConfigId, sections, status (lifecycle), studentsTotal, studentsActive, submissionsCount, lastActivity | +| `SubmissionRow` | aggregate across `conversations` + `submissions` | studentId, name, initials, sectionsProgress, conversationCount, status, lastActivity | + +`recordNumber` is a display-only field — it backs the `HW·xxx` / `CFG·xxx` ID badges. In Phase 1 it will be derived from `created_at` ordering per record type (so `HW·001` is always the oldest homework, `HW·002` the next, etc.). + +`status` on Homework collapses several Django concerns into one lifecycle enum: `"draft" | "scheduled" | "active" | "past_due" | "archived"`. Today's logic: scheduled = future due date and no sections submitted; active = currently before due date with sections in flight; past_due = due date passed with at least one student still active; archived = closed-out. Real Drizzle queries will compute this from `dueDate` + section/submission aggregations. + +## Sidebar collapse + +Mirrors the student web app's pattern verbatim: + +- Root `