TutorLoom
기업·대학·교육기관을 위한 학습 플랫폼, 무료 오픈소스이며 엔터프라이즈 버전 제공
저장소 정보는 아직 수집하지 않았습니다.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
Lucas placement management system
저장소 README에서 가져옴
README 전체를 불러오는 중입니다.
README 전문 보기 ↗Running GradPonto rather than developing it? Start with OWNERS_GUIDE.md: what it does, what it runs on, spending limits, and what to do when something breaks.
A web app for UK graduates to track placement / graduate job applications: log opportunities, tailor a CV and cover letter per job (optionally with AI help), export a finished PDF, and track each application's status from first interest through to an offer, on a dashboard, a card grid, or a drag-and-drop kanban pipeline.
People sign in with their email (a one-time code, no password), register with their name and university, and only ever see their own data. See Accounts & sign-in.
The public marketing page is at /landing and its Get started free button leads to sign-in.
prisma/dev.db6.19.3, classic engine — see ARCHITECTURE.md for why)next-themes@base-ui/react)@anthropic-ai/sdk, with Google Gemini (free key from AI Studio, plain fetch) as the backup. The app works fully without a key, with the AI features simply disabled@react-pdf/rendererprisma/
schema.prisma The data model — User, Session, LoginCode, plus Employer, Application, Document, Activity, Goal (each owned by a User)
migrations/ Auto-generated SQL migrations
dev.db The SQLite database file (gitignored — it's your real data)
src/
app/
layout.tsx Minimal root layout — theme provider + toast host only
landing/ Public marketing page (GradPonto landing page)
signin/, welcome/ Sign-in (email + code) and registration (name + university)
dev/outbox/ Development-only inbox for emails when SMTP isn't configured
(app)/ Route group for every page that gets the sidebar shell
layout.tsx Wraps children in <AppShell>
page.tsx Dashboard
applications/ List (card grid), create, edit, detail + per-application document editor
pipeline/ Kanban board, drag-and-drop between statuses
deadlines/ Grouped: overdue / due within 30 days / later
analyse-job/ Paste a job posting (or a URL) → AI-extracted fields → pre-filled application
check-cv/ Application Reviewer — tabbed CV/cover-letter tools, mode kept in ?mode=
employers/ List, create, edit, detail ("Companies" in the sidebar)
documents/ Manage named CV and cover-letter versions (upload a file or paste text)
account/ Local profile name + Free/Pro plan toggle
api/documents/[id]/pdf Route handler that renders a Document to a downloadable PDF
components/
ui/ Generated shadcn/ui primitives — do not hand-edit, re-run the CLI instead
layout/ Sidebar + header shell, theme toggle, search
landing/ Landing-page sections and the header with the Get started free button
auth/ Sign-in, code and registration forms
brand/ GradPonto mark and wordmark
dashboard/, applications/, pipeline/, documents/, analyse-job/, check-cv/, employers/, activities/
Feature-specific components
shared/ Small reusable pieces (PageHeader, EmptyState, Pagination, DeleteButton, LinkButton)
lib/
db.ts Prisma client singleton
validations.ts zod schemas for every form
labels.ts Enum → human label / colour maps, shared between server and client
ai/ client.ts (generateText: Claude, falling back to Gemini; JSON helper), limit.ts (per-user hourly allowance), tailor.ts, analyze-job.ts, match-cv.ts
pdf/render.ts React-PDF template + render-to-buffer helper
data/ Read-only query functions, one file per entity
actions/ `"use server"` mutations, one file per entity
Every page follows the same shape: a server component in app/ calls a function from lib/data/*, passes the result to components in components/, and forms call a "use server" function from lib/actions/*. There's no separate REST API layer for CRUD — Next.js Server Actions handle mutations directly. The two exceptions are the PDF export (needs to stream a binary file with Content-Disposition: attachment) and the Pipeline board's drag-and-drop (a client component calling a server action directly, with optimistic local state — see ARCHITECTURE.md).
Start the dev server:
npm run dev
Then open http://localhost:3000 for the app, or http://localhost:3000/landing for the marketing page.
To stop it, press Ctrl+C in the terminal it's running in (or close the terminal).
For a production-style run:
npm run build
npm run start
The database is a single SQLite file at prisma/dev.db. It is not committed to git (see .gitignore) — it holds your real CV content, job descriptions, and application history, which is personal data you don't want in version control or a shared repo.
Whenever you (or an AI assistant) change prisma/schema.prisma, apply the change with:
npx prisma migrate dev --name <short-description>
This updates dev.db in place and writes a new folder under prisma/migrations/. Commit the migration folder — that's the part of the schema history that should be in git.
To wipe all data and start over:
npx prisma migrate reset
This drops and recreates dev.db from the migrations, then reruns any seed script (none exists yet).
npx prisma studio
Opens a local GUI at http://localhost:5555 for browsing and editing rows directly — handy for debugging.
Three features use AI, and all three are entirely optional — each degrades gracefully (buttons disable themselves, with a note pointing at this section) if no key is configured:
/check-cv, formerly "Check My CV") — a tabbed page for CV/cover-letter tools. Today it has one working tab, Job Match, which compares a saved CV against a specific application's job description and returns a match score, strengths, gaps, and concrete suggestions. Health Check, ATS Keywords, and Cover Letter are reserved tabs, shown as "coming soon" until they ship.They use Anthropic's Claude API, with Google's Gemini API as a free backup. Either key alone is enough. To enable them:
.env (which is git-ignored — never commit a key):
ANTHROPIC_API_KEY=...
GEMINI_API_KEY=...
Claude is tried first, with claude-haiku-5-5 at low effort (set CLAUDE_MODEL to change it). If Claude fails (an outage, or the spend limit is reached), the request falls back to Gemini, whose model defaults to gemini-3.8-flash (GEMINI_MODEL). Each person is limited to 30 AI requests an hour (src/lib/ai/limit.ts), on top of the daily caps in src/lib/limits.ts.
AI-generated document content is always labeled with an "AI drafted" badge so you remember to review it before using it.
Find placements (top of the Track group in the left menu) searches live UK adverts through the Adzuna API and lets you add any result to your pipeline with one click ("Add to pipeline" creates an Interested application with the link, location, salary and a summary; adverts already added show "In your pipeline").
Setup: register at https://developer.adzuna.com/ and add to .env:
ADZUNA_APP_ID=...
ADZUNA_APP_KEY=...
How it behaves (ported from the original Python search harness):
what_or, then results are de-duplicated and ranked. Student-friendly roles (placements, internships, graduate schemes) are boosted, staff roles ("Placement Officer", "Head of…") are pushed down, and fee-charging "training courses" are hidden behind a visible "Show it" link.public/adzuna-logo.svg (or .png) and it is used automatically; otherwise a linked text credit is shown. Adzuna's predicted salaries are never displayed (they need a separate credit).Pure logic lives in src/lib/jobs/listing.ts and is covered by npm test.
On Pipeline, pick a card up and drop it in another column to change its status (the change is saved, logged in the application's timeline, and shown immediately; if saving fails it jumps back and you are told). Mouse: drag. Touch: press and hold, then drag. Keyboard: focus a card, Space to pick up, Left/Right arrows to change column, Space to drop, Escape to cancel. A plain click on the company name still opens the application.
Two ways in, both on /signin and /signup:
/signup takes an email and password, emails a 6-digit code (valid 10 minutes, single use) and the account is verified once the code is entered on /signup/verify. After that, /signin is just email and password. /forgot-password resets it with another emailed code./signup/verify page. That creates the account (or, if the email already has one, links to it). Every later sign-in with that Google/Microsoft account goes straight in with no code. Apple is shown as "Soon" (it needs a paid Apple Developer membership).Either way, a new account then goes to /welcome for name and university, which completes registration and sends a confirmation email.
Copy .env.example to .env and set at least DATABASE_URL (a Postgres connection string), AUTH_SECRET (random, 32+ characters; the file explains how to generate one) and APP_URL.
Each button stays greyed out until both of its keys are set. The code is in src/lib/auth/oauth.ts, src/lib/auth/oauth-core.ts and src/app/api/auth/oauth/[provider]/. Linked accounts are stored in the OAuthAccount table.
Each provider needs a redirect URI registered: APP_URL + /api/auth/oauth/<provider>/callback. Register both the local and the live one:
http://localhost:3000/api/auth/oauth/google/callback and https://gradponto.com/api/auth/oauth/google/callbackmicrosoft in place of googleVercel preview deployments use the live APP_URL, so provider sign-in on a preview URL sends you back to the live site. Test it locally or on production.
Google (Google Cloud Console):
openid, email and profile. While it's in Testing mode only the test users you list can sign in; Publish app to open it to everyone. The basic scopes don't need Google's review.GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET.Microsoft (Microsoft Entra admin center → App registrations → New registration):
MICROSOFT_CLIENT_ID.MICROSOFT_CLIENT_SECRET. Secrets expire (the maximum is 2 years), so note the date.For the live site, add the same four variables in Vercel (Settings → Environment Variables) and redeploy.
Email import (in the sidebar) gives each user a private forwarding address, <token>@in.gradponto.com. They point a Gmail filter at it (the page shows the exact filter text and walks them through Gmail's forwarding confirmation, whose code/link it displays), and can bring in older mail with Gmail's Forward as attachment. Each email is pre-filtered (must be addressed to one of the user's own addresses, which drops spam to strangers; job-board newsletters and alerts are skipped), classified by the AI, matched to their applications and shown as a suggestion. Nothing is written to applications until a suggestion is approved.
Code: src/lib/inbound/ (core.ts is the pure filtering/matching/parsing logic, tested in tests/email-import.test.ts; ingest.ts files incoming mail; process.ts classifies the queue), src/app/api/inbound/postmark/route.ts (the webhook), src/lib/ai/classify-email.ts, src/app/(app)/email-import/. Email bodies are deleted once classified; only the subject, sender and a short snippet are kept.
Setup (DNS for gradponto.com is at Wix):
in.gradponto.com.https://postmark:<INBOUND_WEBHOOK_SECRET>@gradponto.com/api/inbound/postmark (the secret is the password; the user name is ignored).in, value inbound.postmarkapp.com, priority 10. Only the in. subdomain changes; mail to @gradponto.com itself is unaffected.INBOUND_EMAIL_DOMAIN=in.gradponto.com and INBOUND_WEBHOOK_SECRET=<the same random secret>. Redeploy.Limits: a webhook body over 4.5 MB is refused by Vercel, so "Forward as attachment" batches should be ~25 emails. Each inbox accepts up to 120 messages an hour, and AI classification uses the user's normal hourly AI allowance (anything over waits in the queue; the page has a "Process" button).
SMTP_HOST): emails are not sent. They are saved in .mail-outbox/ and listed at http://localhost:3000/dev/outbox, which is where you read your sign-in code. That page returns 404 in production.SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS and MAIL_FROM. Without SMTP configured in production, sign-in fails loudly rather than pretending to send.npm run mail:test -- [email protected]. It connects, logs in and sends one real email, and explains the likely cause if anything is wrong. Restart the dev server afterwards so it picks up .env. .env.example has a quick Gmail recipe for testing.Every user-owned table has a userId. Application code never uses the raw Prisma client; it gets a client from userDb() (src/lib/auth/user.ts) that adds userId to every query and stamps it on every insert. src/lib/db.ts exports the raw client as prisma on purpose so any unscoped use is obvious (and import { db } no longer compiles). Server actions and route handlers each re-check the session themselves; src/proxy.ts only does a quick cookie check and is not the security boundary. Where an action accepts an id that links two records (for example an activity's applicationId), it first checks that the id belongs to the signed-in user.
AUTH_SECRET and a real SMTP provider, and serve over https (APP_URL=https://..., which also marks cookies Secure).src/lib/auth/rate-limit.ts is in-memory and per server process; use a shared store (for example Redis) if you run more than one instance.[bracketed] placeholders).User and assigns the old rows to it.The codebase is intentionally light on abstraction so it stays easy to extend:
schema.prisma, run a migration, then copy the shape of lib/data/employers.ts + lib/actions/employers.ts + components/employers/* + app/(app)/employers/* — every entity in this app follows that same four-piece pattern.schema.prisma, migrate, then add it to the relevant zod schema in lib/validations.ts, the form component, and the detail page.Application.appliedAt field and the Activity timeline (with a STATUS_CHANGE type already auto-logged) exist so a future integration has a natural place to read from and write to. The cleanest path is a local MCP server for email/calendar — see the note below.This app doesn't talk to email or calendars yet — that was explicitly scoped out of v1. When you're ready to add it, the natural shape is a local MCP server (or a small dedicated script) that:
Application rows with status = APPLIED and a recent appliedAt,updateApplicationStatus server action (or writes to the DB directly) to advance status and logs an Activity with type STATUS_CHANGE or NOTE.Nothing in the current schema needs to change to support this — it's additive.
If this ever needs multi-device or networked access beyond a single machine:
docker run -e POSTGRES_PASSWORD=... -p 5432:5432 postgres).prisma/schema.prisma, change:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
DATABASE_URL in .env to a Postgres connection string.npx prisma migrate dev to recreate the schema on the new database (this does not copy existing SQLite data — export/import data separately if needed, e.g. via prisma db seed or a one-off script).No application code needs to change — all database access goes through Prisma, which abstracts the SQL dialect differences.
Accounts and per-user data are in place (see Accounts & sign-in), but review Before going live first. Then:
npm run build && npm run start (Next.js listens on all interfaces by default with next start, or bind explicitly with next start -H 0.0.0.0).http://<your-machine-ip>:3000.The /account page's Free/Pro toggle is a simulation: no payment processor is connected and nothing is charged. To make this a real subscription:
listen --forward-to) for local development.User (User.plan).setPlan in lib/actions/profile.ts with a call into the payment provider's checkout flow, and update User.plan from its webhook events rather than directly from a button click.