From a2d1978a8b6b171571ae1cc0b6131c8a294fbb20 Mon Sep 17 00:00:00 2001 From: Futuregh Date: Thu, 17 Sep 2026 01:07:02 +0000 Subject: [PATCH 1/7] Bucket-backed DB persistence + dual-workspace Phase 1 (Workspace, Stage, Outreach) --- .env.example | 10 + .gitattributes | 2 + Dockerfile | 4 +- PRD.md | 509 ++++++++++++++++++ __tests__/bucket-sync.spec.ts | 204 +++++++ __tests__/outreach-follow-up.spec.ts | 24 + __tests__/outreach.actions.spec.ts | 62 +++ __tests__/workspace.actions.spec.ts | 55 ++ docker-entrypoint.sh | 14 + .../migration.sql | 119 ++++ prisma/schema.prisma | 77 +++ scripts/seed-school-workspace.mjs | 71 +++ scripts/sync-db-bucket.mjs | 199 +++++++ src/actions/outreach/outreach.actions.ts | 68 +++ src/actions/workspace/workspace.actions.ts | 72 +++ src/components/osui/workspace-switcher.tsx | 48 ++ src/lib/outreach/follow-up.ts | 17 + src/lib/workspaces/defaults.ts | 25 + 18 files changed, 1579 insertions(+), 1 deletion(-) create mode 100644 .gitattributes create mode 100644 PRD.md create mode 100644 __tests__/bucket-sync.spec.ts create mode 100644 __tests__/outreach-follow-up.spec.ts create mode 100644 __tests__/outreach.actions.spec.ts create mode 100644 __tests__/workspace.actions.spec.ts create mode 100644 prisma/migrations/20260917010308_dual_workspace_outreach/migration.sql create mode 100644 scripts/seed-school-workspace.mjs create mode 100644 scripts/sync-db-bucket.mjs create mode 100644 src/actions/outreach/outreach.actions.ts create mode 100644 src/actions/workspace/workspace.actions.ts create mode 100644 src/components/osui/workspace-switcher.tsx create mode 100644 src/lib/outreach/follow-up.ts create mode 100644 src/lib/workspaces/defaults.ts diff --git a/.env.example b/.env.example index b86a2105d..c2e55b453 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,16 @@ # DATABASE_URL=file:./dev.db +# --- Railway Bucket (S3-compatible) persistence for the DB file --- +# Replaces the old Volume mount. Copy these from the bucket's Credentials tab. +# AWS_ENDPOINT_URL=https://t3.storageapi.dev +# AWS_ACCESS_KEY_ID= +# AWS_SECRET_ACCESS_KEY= +# AWS_S3_BUCKET_NAME= +# AWS_DEFAULT_REGION=auto +# S3_PATH_STYLE=false # Railway buckets use virtual-hosted style +# DB_OBJECT_KEY=db/dev.db # object key for the DB file (default: db/) + # If you are running it on a remote server/homelab, you must update timezone otherwise activities times may shift TZ=America/Edmonton diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..f3f6bec5a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.sh text eol=lf +Dockerfile text eol=lf diff --git a/Dockerfile b/Dockerfile index 5388e1ecc..4db7c3d7b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,11 +38,13 @@ RUN addgroup --system --gid 1001 nodejs && \ RUN mkdir .next RUN chown nextjs:nodejs .next -# Set up /data directory with the right permissions +# /data is ephemeral local disk — durability comes from the Railway Bucket +# (see scripts/sync-db-bucket.mjs). No volume mount required. RUN mkdir -p /data/files/resumes && chown -R nextjs:nodejs /data/files/resumes COPY --from=builder /app/public ./public COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/scripts ./scripts COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static diff --git a/PRD.md b/PRD.md new file mode 100644 index 000000000..ac3d8a49d --- /dev/null +++ b/PRD.md @@ -0,0 +1,509 @@ +# PRD — Dual-Workspace Application Tracker (School + Job) + +**Product:** Application Tracker (working name: *Tracker*) +**Base:** JobSync (MIT) — deployed at https://jobba.up.railway.app +**Repo:** `D:\my apps\reclip\jobsync` · Railway project `jobsync-tracker` +**UI source (only):** [opensourceui.in](https://opensourceui.in) — MIT, copy-paste React/Next.js, Tailwind v4, Lucide +**Owner:** Samuel Danquah Ankapong +**Status:** Planning — implement next session + +--- + +## 1. Vision + +One self-hosted application tracker that handles **two kinds of applications** in **separate workspaces**: + +- **School workspace** — PhD / Master's applications: universities, professors (PIs), programmes, funding, deadlines, documents (CV, SOP, transcripts), cold-email outreach, interviews. +- **Job workspace** — jobs: companies, roles, recruiters, sources, CV variants, applications, interviews, offers. + +Same engine, two skins and two field-sets. Switch between them instantly. + +**Why:** Samuel is running ~8 live PhD applications and a job search in parallel. Generic trackers model only *jobs*. Nothing models *"emailed Prof X about paper Y on date Z, follow up on date W, funding status, SOP version"* — which is the real PhD workflow. + +--- + +## 2. Goals / Non-goals + +### Goals +1. **Workspace switch** between School and Job with independent pipelines and fields. +2. Model **academic entities** natively: University, Programme, Professor/PI, Funding, Deadline, Document (SOP/CV/transcript/proposal), Outreach. +3. Track the **cold-email lifecycle** (sent → follow-up due → replied → meeting → outcome). +4. Unified **deadline dashboard** across both workspaces. +5. UI overhauled using **only opensourceui.in components** + Tailwind. +6. Keep it **self-hosted**, cheap, and agent-friendly (MCP). + +### Non-goals (v1) +- No auto-apply / auto-email sending (recruiters detect it; also unethical for academia). +- No multi-user teams/orgs. Single user. +- No job-board scraping (JobOps does that; not needed now). +- No native mobile app — responsive web only. + +--- + +## 3. Current state (baseline) + +| Item | Value | +|---|---| +| App | JobSync (Next.js 15, React, Prisma + SQLite, NextAuth, Vercel AI SDK, MCP server) | +| Auth | NextAuth, email+password, `AUTH_SECRET`, `AUTH_TRUST_HOST=true` | +| DB | SQLite at `/data/dev.db`, 5 GB Railway volume `jobsync-volume` | +| Deploy | Railway, Dockerfile build, domain https://jobba.up.railway.app | +| Styling | Tailwind + shadcn/ui (being replaced) | +| MCP | Built-in MCP server (`/api/mcp`), tokens in Settings | + +Existing Prisma models we inherit: `User, Job, JobTitle, Location, Company, JobSource, Contact, ContactRole, Profile, Activity, ActivityType, Task, Automation, UserSettings, ApiKey, Note, Tag, Question, McpAccessToken, ChatConversation`. + +--- + +## 4. Core concept — Workspaces + +``` +User + └── Workspace (type: SCHOOL | JOB) + ├── Pipeline (ordered Stages) + ├── Applications + │ ├── Organization (University | Company) + │ ├── Contacts (PIs/Professors | Recruiters) + │ ├── Documents (CV/SOP/Transcript | CV variants) + │ ├── Outreach (cold emails) + │ ├── Tasks & Deadlines + │ └── Notes / Questions + └── Settings (fields, stages, scoring) +``` + +**Workspace switcher** is the top-level nav control (opensourceui.in `workspace-switcher`). + +### Pipeline defaults (editable) + +| Stage | SCHOOL | JOB | +|---|---|---| +| 1 | Researching | Saved | +| 2 | Outreach sent | Applied | +| 3 | Applied | Screening | +| 4 | Interview | Interview | +| 5 | Offer | Offer | +| 6 | Rejected / Withdrawn | Rejected / Withdrawn | + +--- + +## 5. Domain model changes (Prisma) + +### 5.1 New models + +```prisma +model Workspace { + id String @id @default(uuid()) + userId String + name String + type WorkspaceType // SCHOOL | JOB + createdAt DateTime @default(now()) + user User @relation(fields:[userId], references:[id]) + apps Application[] + stages Stage[] + fields WorkspaceField[] // custom field defs per workspace +} + +enum WorkspaceType { SCHOOL JOB } + +model Stage { + id String @id @default(uuid()) + workspaceId String + name String + order Int + isTerminal Boolean @default(false) + color String? +} +``` + +### 5.2 Generalise the application record + +Rename `Job` → **`Application`** (keep a compatibility view during migration). + +```prisma +model Application { + id String @id @default(uuid()) + workspaceId String + orgId String? // University | Company + title String // Role title | Programme name + kind String? // phd | masters | fellowship | fulltime | internship + stageId String + deadline DateTime? + appliedAt DateTime? + sourceUrl String? + location String? + // SCHOOL-specific + fundingStatus String? // funded | self-funded | scholarship | unknown + stipendAmount String? + supervisorId String? // primary PI (Contact) + programmeRef String? // e.g. vacancy ref 2592 + // shared + matchScore Int? + notes Note[] + tasks Task[] + docs DocumentLink[] + contacts ContactRole[] + outreach Outreach[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Organization { // was Company + id String @id @default(uuid()) + name String + type String? // university | institute | company + country String? + website String? + notes String? +} + +model Document { // was Profile/resume, generalised + id String @id @default(uuid()) + name String + kind String // cv | sop | transcript | proposal | cover_letter + version Int @default(1) + filePath String? + content String? // markdown/text + createdAt DateTime @default(now()) +} + +model DocumentLink { // attach a doc+version to an application + applicationId String + documentId String + role String? // submitted | tailored | reference +} + +model Outreach { // NEW — the cold-email lifecycle + id String @id @default(uuid()) + applicationId String + contactId String + channel String @default("email") + subject String? + body String? + paperCited String? // which paper the email referenced + sentAt DateTime? + followUpDue DateTime? + repliedAt DateTime? + outcome String? // no_reply | positive | negative | meeting | offer + createdAt DateTime @default(now()) +} + +model WorkspaceField { // user-defined custom fields + id String @id @default(uuid()) + workspaceId String + key String + label String + type String // text | number | date | select | url + options String? // JSON for select +} +``` + +### 5.3 Reuse as-is +`Contact`, `ContactRole`, `Note`, `Tag`, `Task`, `Activity`, `ActivityType`, `Question`, `ApiKey`, `UserSettings`, `McpAccessToken`, `ChatConversation`. + +### 5.4 Migration plan +1. Add `Workspace`; backfill **one JOB workspace** per user. +2. `Job` → `Application` via Prisma rename + `workspaceId` backfill. +3. `Company` → `Organization`. +4. `Profile` → `Document` (kind=`cv`). +5. SQLite is fine through migration; consider **Postgres** later for concurrency (Railway add `postgres`, swap `DATABASE_URL`). + +--- + +## 6. Feature spec per workspace + +### 6.1 Shared (both) +- Applications table with **server-side sort/filter/search**, column visibility, saved views. +- Pipeline **board view** (kanban by stage). +- **Deadline calendar** (month + week) with countdown. +- Application detail page: overview, contacts, documents, outreach, tasks, notes, activity. +- Document library with **versions** and per-application links. +- Task/deadline engine with reminders (email or toast). +- Global **command palette** (⌘K). +- Dashboard: counts by stage, upcoming deadlines, stale applications, response rate. +- MCP server for agent-driven creates/updates. + +### 6.2 School-specific +- **Professor/PI directory** — contact role = PI; fields: research area, lab, papers read, outreach history. +- **Funding tracker** — funded / self-funded / scholarship, stipend, fee waiver, visa rules. +- **Programme metadata** — degree, duration, start date, application portal, reference code. +- **Outreach pipeline** (the differentiator): each application can hold many `Outreach` rows — one per professor emailed — with paper cited, date sent, follow-up due, reply outcome. +- **Document set** — SOP per programme, CV, transcripts, research proposal, referees. +- **Eligibility notes** — bachelor's vs master's requirement, English test, ECTS. + +### 6.3 Job-specific +- **Recruiter/referee contacts**, referral tracking. +- **CV tailoring** per role (document variants). +- **Source tracking** (LinkedIn, referral, company site) + response rates per source. +- Salary/comp fields. + +--- + +## 7. UI overhaul — design language + +**Source:** opensourceui.in only. Copy-paste into `components/osui/*`; we own the files. + +**Aesthetic commitment:** opensourceui.in is **light-only** ("paper white, ink text, quiet borders"). Adopting it means JobSync goes **light-first**. We add a dark variant ourselves later if needed — the library itself ships none. + +**Stack alignment check (do first):** JobSync must be on **Tailwind v4**. If it's v3, run the v3→v4 migration before copying components. + +**Rules we adopt:** hairline borders, neutral stage, accents only for state; focus = border change not coloured ring; motion respects `prefers-reduced-motion`; responsive with `md:` base (not `sm:`). + +--- + +## 8. Component mapping (opensourceui.in → screens) + +### 8.1 App shell / navigation + +| Need | opensourceui.in component | +|---|---| +| Workspace switch (School ↔ Job) | [`workspace-switcher`](https://opensourceui.in/components/workspace-switcher) | +| Account menu | [`user-menu`](https://opensourceui.in/components/user-menu) | +| Notifications tray | [`notification`](https://opensourceui.in/components/notification) | +| Command palette (⌘K) | [`spotlight-bar`](https://opensourceui.in/components/spotlight-bar) | +| View-mode switch (Table/Board/Calendar) | [`segmented-toggle-button`](https://opensourceui.in/components/segmented-toggle-button) | +| Bottom/stage nav (desktop) | [`mac-dock`](https://opensourceui.in/components/mac-dock), [`app-dock`](https://opensourceui.in/components/app-dock) | +| Collaborator presence | [`presence-dock`](https://opensourceui.in/components/presence-dock) | + +### 8.2 Applications list & board + +| Need | Component | +|---|---| +| Applications table (checkbox, assignee, due date) | [`tasks-table`](https://opensourceui.in/components/tasks-table) | +| Alternative dense list | [`orders-table`](https://opensourceui.in/components/orders-table) | +| Bulk-select rows | [`users-select-table`](https://opensourceui.in/components/users-select-table) | +| Contact/people list | [`team-members-table`](https://opensourceui.in/components/team-members-table) | +| Row actions (⋯) | [`kebab-actions`](https://opensourceui.in/components/kebab-actions) | +| Column/field menu | [`file-menu`](https://opensourceui.in/components/file-menu) | +| Filter / sort control | [`filter-sort`](https://opensourceui.in/components/filter-sort), [`select-field-input`](https://opensourceui.in/components/select-field-input) | +| Progress across stages | [`progress-ring`](https://opensourceui.in/components/progress-ring) | + +### 8.3 Deadlines & calendar + +| Need | Component | +|---|---| +| Full month view | [`month-picker-calendar`](https://opensourceui.in/components/month-picker-calendar) | +| Week strip | [`week-strip-calendar`](https://opensourceui.in/components/week-strip-calendar) | +| Date range (e.g. intake window) | [`date-range-picker`](https://opensourceui.in/components/date-range-picker) | +| Slot booking (interview) | [`booking-slot-calendar`](https://opensourceui.in/components/booking-slot-calendar) | +| Deadline countdown card | [`event-countdown-card`](https://opensourceui.in/components/event-countdown-card) | +| Activity heatmap | [`daily-activity-calendar`](https://opensourceui.in/components/daily-activity-calendar), [`github-contribution`](https://opensourceui.in/components/github-contribution) | + +### 8.4 Forms (create/edit application, PI, outreach) + +| Need | Component | +|---|---| +| Text | [`text-field-input`](https://opensourceui.in/components/text-field-input) | +| Long text / notes | [`textarea-field-input`](https://opensourceui.in/components/textarea-field-input) | +| Select (stage, funding) | [`select-field-input`](https://opensourceui.in/components/select-field-input) | +| Searchable select (university/PI) | [`combobox-field-input`](https://opensourceui.in/components/combobox-field-input) | +| Date (deadline) | [`date-field-input`](https://opensourceui.in/components/date-field-input) | +| URL with prefix | [`input-group-field`](https://opensourceui.in/components/input-group-field) | +| Attach files (CV/SOP/transcript) | [`file-upload-field-input`](https://opensourceui.in/components/file-upload-field-input) | +| Checkbox / Radio / Switch | [`checkbox-field-input`](https://opensourceui.in/components/checkbox-field-input), [`radio-group-field-input`](https://opensourceui.in/components/radio-group-field-input), [`switch-field-input`](https://opensourceui.in/components/switch-field-input) | +| Quick filter search | [`search-input`](https://opensourceui.in/components/search-input) | +| Floating-label form | [`floating-label-field-input`](https://opensourceui.in/components/floating-label-field-input) | + +### 8.5 Auth + +| Need | Component | +|---|---| +| Login | [`login-form`](https://opensourceui.in/components/login-form) | +| Signup | [`signup-form`](https://opensourceui.in/components/signup-form) | +| Password reset | [`forgot-password-form`](https://opensourceui.in/components/forgot-password-form) | +| 2FA / email code | [`otp-boxed-input`](https://opensourceui.in/components/otp-boxed-input), [`otp-underline-input`](https://opensourceui.in/components/otp-underline-input) | + +### 8.6 Contacts — recruiters & professors + +| Need | Component | +|---|---| +| PI / recruiter detail card | [`contact-profile`](https://opensourceui.in/components/contact-profile) | +| Roster / directory | [`editorial-staff-profile`](https://opensourceui.in/components/editorial-staff-profile), [`team-member-profile-grid`](https://opensourceui.in/components/team-member-profile-grid) | +| Individual profile | [`user-profile`](https://opensourceui.in/components/user-profile), [`blob-profile`](https://opensourceui.in/components/blob-profile) | +| Professional profile | [`linked-in-profile`](https://opensourceui.in/components/linked-in-profile) | + +### 8.7 Documents (CV, SOP, transcripts, proposals) + +| Need | Component | +|---|---| +| Document row / filing card | [`ink-stamp-document`](https://opensourceui.in/components/ink-stamp-document) | +| Folder grouping | [`stacked-folder-card`](https://opensourceui.in/components/stacked-folder-card), [`opensource-folder-tab-card`](https://opensourceui.in/components/opensource-folder-tab-card) | +| CV/editor preview | [`journal-writing`](https://opensourceui.in/components/journal-writing) (word count, save indicator) | + +### 8.8 Feedback / states + +| Need | Component | +|---|---| +| Toasts ("Applied saved") | [`toast-notification`](https://opensourceui.in/components/toast-notification) | +| Alert banner | [`system-alert`](https://opensourceui.in/components/system-alert) | +| Loading | [`spin-loader`](https://opensourceui.in/components/spin-loader), [`text-loader`](https://opensourceui.in/components/text-loader) | +| Empty states | [`diagonal-box-pattern`](https://opensourceui.in/components/diagonal-box-pattern), [`dot-grid-pattern`](https://opensourceui.in/components/dot-grid-pattern) as backdrop | +| Buttons | [`3d-button`](https://opensourceui.in/components/3d-button), [`depth-outline-button`](https://opensourceui.in/components/depth-outline-button), [`soft-pill-button`](https://opensourceui.in/components/soft-pill-button), [`slide-to-confirm-button`](https://opensourceui.in/components/slide-to-confirm-button) (destructive) | +| Danger confirm ("Delete application") | [`hold-to-delete-button`](https://opensourceui.in/components/hold-to-delete-button), [`slide-to-confirm-button`](https://opensourceui.in/components/slide-to-confirm-button) | + +### 8.9 Dashboard widgets + +| Need | Component | +|---|---| +| Tasks today | [`minimal-agenda`](https://opensourceui.in/components/minimal-agenda) | +| Stat tile | [`step-count`](https://opensourceui.in/components/step-count) | +| Countdown / timer | [`pomodoro`](https://opensourceui.in/components/pomodoro), [`stopwatch`](https://opensourceui.in/components/stopwatch) | +| Notifications feed | [`email-notification`](https://opensourceui.in/components/email-notification), [`calendar-reminder-notification`](https://opensourceui.in/components/calendar-reminder-notification), [`deploy-notification`](https://opensourceui.in/components/deploy-notification) | +| Backgrounds for hero sections | [`aurora-background`](https://opensourceui.in/components/aurora-background), [`frost-mesh-background`](https://opensourceui.in/components/frost-mesh-background), [`dot-grid-pattern`](https://opensourceui.in/components/dot-grid-pattern), [`graph-paper-pattern`](https://opensourceui.in/components/graph-paper-pattern) | + +--- + +## 9. Screen inventory (build list) + +| # | Screen | Key components | +|---|---|---| +| 1 | Sign in / Sign up | `login-form`, `signup-form` | +| 2 | **Workspace picker** | `workspace-switcher` | +| 3 | Dashboard (per workspace) | `step-count`, `minimal-agenda`, `event-countdown-card`, `progress-ring`, `toast-notification` | +| 4 | Applications — Table view | `tasks-table`, `filter-sort`, `search-input`, `kebab-actions` | +| 5 | Applications — Board view | own kanban, cards from `ink-stamp-document` style | +| 6 | Applications — Calendar view | `month-picker-calendar`, `week-strip-calendar` | +| 7 | Application detail | `contact-profile`, `ink-stamp-document`, `journal-writing`, `kebab-actions` | +| 8 | New/Edit application | full form component set (§8.4) | +| 9 | **Contacts / PIs directory** | `team-member-profile-grid`, `editorial-staff-profile` | +| 10 | Contact detail | `contact-profile`, `linked-in-profile` | +| 11 | **Outreach log** (cold emails) | `tasks-table` variant, `event-countdown-card` for follow-ups | +| 12 | Documents library | `stacked-folder-card`, `ink-stamp-document` | +| 13 | Tasks & deadlines | `minimal-agenda`, `tasks-table` | +| 14 | Question bank | `tasks-table` / note list | +| 15 | Settings (workspace, stages, fields, MCP) | `segmented-toggle-button`, form set | +| 16 | Global search / ⌘K | `spotlight-bar` | + +--- + +## 10. Technical architecture + +- **Framework:** Next.js 15 App Router (inherited). +- **DB:** Prisma + SQLite (v1). Optional Postgres on Railway when concurrency/reporting grows. +- **Auth:** NextAuth (inherited). +- **Styling:** Tailwind v4 + opensourceui.in components copied into `components/osui/`. +- **State/data:** React Server Components + server actions; TanStack Table for the data grid logic (styled with osui). +- **Files:** Railway volume `/data/files/…`. +- **Jobs/reminders:** a small cron (Railway cron or a `/api/cron` route) that scans `Task.dueAt` and `Outreach.followUpDue` and emails reminders. +- **MCP:** extend the inherited MCP server with tools: `create_application`, `log_outreach`, `set_deadline`, `add_contact`, `update_stage`. +- **Railway:** same project; add a cron service + (later) Postgres. + +--- + +## 11. Cold-email → tracker integration + +This closes the loop on the work already done in `CAS-cold-emails/` and `other-cold-emails/`: + +- Each cold email becomes an **Outreach** row linked to an Application + Contact. +- Fields captured: professor, paper cited, subject, body, sent date, **follow-up due (sent + 10–14 days)**, reply, outcome. +- The tracker surfaces **"follow-up due today"** — which is exactly the rule from the Reddit research (one follow-up, 10–14 days). +- Auto-create Applications from the 8 drafted emails: Vanderbilt/Dong, NYU/Su, Dresden/Speidel, Twente/Misra, KU Leuven/VanderPoorten, Utah/George, Northeastern/Shepherd (+ Imperial & HERON marked CLOSED). + +--- + +## 12. Roadmap + +### Phase 0 — prep (½ day) +- [ ] Confirm Tailwind version; migrate to v4 if needed. +- [ ] Create `components/osui/` and copy the shell + form + table components from §8. +- [ ] Set the light "paper/ink" theme tokens. + +### Phase 1 — data model (1 day) +- [ ] Prisma: add `Workspace, Stage, Outreach, Document, DocumentLink, Organization, WorkspaceField`; rename `Job→Application`, `Company→Organization`, `Profile→Document`. +- [ ] Migrate + backfill; seed default stages per workspace type. +- [ ] Create the two workspaces for Samuel's user. + +### Phase 2 — shell + workspaces (1 day) +- [ ] `workspace-switcher` in header; workspace-scoped routing `/w/[workspaceId]/…`. +- [ ] Dashboard with counts + upcoming deadlines. + +### Phase 3 — applications CRUD (2 days) +- [ ] Table view (sort/filter/search/saved views) with `tasks-table` styling. +- [ ] Board view (kanban by stage, drag-drop). +- [ ] Calendar view (month/week) reading `deadline` + `followUpDue`. +- [ ] Detail page + full create/edit form. + +### Phase 4 — school-specific (2 days) +- [ ] PI/professor directory + contact roles. +- [ ] Funding + programme fields. +- [ ] **Outreach log** with follow-up engine. +- [ ] Document library with versions (SOP/CV/transcript/proposal). + +### Phase 5 — polish + agent (1 day) +- [ ] Command palette, toasts, empty states, destructive confirms. +- [ ] MCP tools for create/log-outreach/update-stage. +- [ ] Import Samuel's 8 applications from the email drafts. + +### Phase 6 — deploy (½ day) +- [ ] Railway redeploy; add cron service for reminders; verify SUCCESS + HTTP 200. + +**Total: ~8 working days.** + +--- + +## 13. Risks & decisions + +| Risk / decision | Notes | +|---|---| +| **opensourceui.in is light-only** | Committing to a light UI. Accept, or add our own dark tokens later (library won't provide them). | +| **It's a presentational catalog, not an app kit** | No sidebar/data-grid/kanban logic. We compose the app shell and use TanStack Table for logic; osui supplies the visuals. | +| **Tailwind v4 requirement** | Verify JobSync's Tailwind version first — v3→v4 is a migration. | +| **Prisma rename is destructive** | Do it on a DB backup; the Railway volume snapshot is our rollback. | +| **SQLite concurrency** | Fine single-user; move to Postgres if we add background jobs writing concurrently. | +| **Emails not auto-sent** | Deliberate — academia and recruiters penalise automation. Tracker logs and reminds; human sends. | + +--- + +## 14. Open questions + +1. Should **Documents** hold file binaries (Railway volume) or text/markdown only in v1? +2. Do we want **Postgres** from the start to simplify later phases? +3. Should the **Job workspace** keep JobSync's AI features (CV match, cover letter) or strip them for v1? +4. Reminders: **email** (needs SMTP creds) or in-app **toast/badge** only for v1? +5. Do we keep JobSync's **Automation** (job-board scraping) or disable it in v1? + +--- + +## Appendix A — current deployments + +| | | +|---|---| +| URL | https://jobba.up.railway.app | +| Project | `jobsync-tracker` — `592d3a70-61ea-4c85-b0b4-66c0767802f8` | +| Service | `jobsync` — `49eccc92-9bc6-4755-9238-ec1c42c6cb28` | +| Volume | `jobsync-volume` (5 GB, `/data`) — `9a6d8cd7-a540-44fb-947e-7360141c9cee` | +| Fix applied | `docker-entrypoint.sh` CRLF→LF + `.gitattributes` | + +## Appendix C — storage: Bucket, not Volume (built 2026-09-17) + +**Decision — no DuckDB engine swap:** Prisma (100+ call sites) has no DuckDB +provider, so replacing SQLite with DuckDB means rewriting the whole data +layer for zero single-user benefit. The DB stays **SQLite-on-Prisma**; +durability moves from the Railway Volume to the Railway Bucket. + +| | | +|---|---| +| Bucket | `jobsync-db` (sjc) → S3 name `jobsync-db-ubyp0zs-jz9ytn` | +| Object | `db/dev.db` (+ `-wal`/`-shm` sidecars when present) | +| Boot | `scripts/sync-db-bucket.mjs download` before `prisma migrate deploy` | +| Runtime | background upload every 5 min + final upload on SIGTERM/SIGINT | +| Client | dep-free SigV4 over `fetch` (no AWS SDK — keeps the slim runner lean) | +| Style | virtual-hosted (`S3_PATH_STYLE=false`, per Railway credentials) | +| Volume | `jobsync-volume` detached after first successful bucket-backed deploy | +| Tests | `__tests__/bucket-sync.spec.ts` (17 tests, mocked fetch) | + +Phase 1 also built: `Workspace/Stage/WorkspaceField/Outreach` models + +migration `dual_workspace_outreach`, workspace + outreach server actions, +`WorkspaceSwitcher` osui component, `seed-school-workspace.mjs` (7 PIs). + +## Appendix B — live application targets to seed + +| Deadline | Target | Status | +|---|---|---| +| 1 Oct 2026 | Twente — Prof. Misra (micro-robotics) | OPEN | +| 15 Oct 2026 | Vanderbilt — Prof. Dong (surgical/magnetic robots) | OPEN | +| 30 Oct 2026 | KU Leuven — Prof. Vander Poorten (catheterization) | OPEN | +| 1 Dec 2026 | NYU — Prof. Hao Su (surgical + wearable) | OPEN | +| rolling | Dresden — Prof. Speidel (CAS / shared autonomy) | OPEN | +| before 1 Jan | Utah — Dr George (neurorobotics) | OPEN | +| any time | Northeastern — Dr Shepherd (wearable) | OPEN | +| — | Imperial (Hamlyn), HERON/NTUA | **CLOSED** | diff --git a/__tests__/bucket-sync.spec.ts b/__tests__/bucket-sync.spec.ts new file mode 100644 index 000000000..e552da433 --- /dev/null +++ b/__tests__/bucket-sync.spec.ts @@ -0,0 +1,204 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { mkdtempSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + pick, + getBucketConfig, + resolveDbPath, + dbObjectKey, + dbSyncFiles, + objectUrl, + signS3Request, + s3Head, + s3Get, + s3Put, + downloadDb, + uploadDb, +} from "../scripts/sync-db-bucket.mjs"; + +const CFG = { + endpoint: "https://t3.storageapi.dev", + accessKeyId: "AKIDEXAMPLE", + secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + bucket: "jobsync-db-xyz", + region: "auto", + pathStyle: false, + configured: true, +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("pick", () => { + it("returns first non-blank value", () => { + expect(pick({ A: " ", B: "x" }, "A", "B")).toBe("x"); + expect(pick({}, "A", "B")).toBeUndefined(); + }); +}); + +describe("getBucketConfig", () => { + it("reads AWS_* names", () => { + const cfg = getBucketConfig({ + AWS_ENDPOINT_URL: "https://t3.storageapi.dev", + AWS_ACCESS_KEY_ID: "id", + AWS_SECRET_ACCESS_KEY: "secret", + AWS_S3_BUCKET_NAME: "b", + AWS_DEFAULT_REGION: "auto", + }); + expect(cfg.configured).toBe(true); + expect(cfg.bucket).toBe("b"); + }); + + it("reads S3_* fallback names", () => { + const cfg = getBucketConfig({ + S3_ENDPOINT: "https://t3.storageapi.dev", + S3_ACCESS_KEY_ID: "id", + S3_SECRET_ACCESS_KEY: "secret", + S3_BUCKET: "b", + }); + expect(cfg.configured).toBe(true); + expect(cfg.region).toBe("auto"); + expect(cfg.pathStyle).toBe(true); + }); + + it("reports not configured when anything is missing", () => { + expect(getBucketConfig({}).configured).toBe(false); + expect( + getBucketConfig({ AWS_ENDPOINT_URL: "x", AWS_ACCESS_KEY_ID: "y" }).configured + ).toBe(false); + }); +}); + +describe("resolveDbPath", () => { + it("strips file: prefix", () => { + expect(resolveDbPath("file:/data/dev.db")).toBe("/data/dev.db"); + }); + it("falls back when empty", () => { + expect(resolveDbPath("")).toBe("/data/dev.db"); + expect(resolveDbPath(undefined)).toBe("/data/dev.db"); + }); +}); + +describe("dbObjectKey / dbSyncFiles", () => { + it("defaults to db/", () => { + expect(dbObjectKey("/data/dev.db", {})).toBe("db/dev.db"); + }); + it("honours DB_OBJECT_KEY", () => { + expect(dbObjectKey("/data/dev.db", { DB_OBJECT_KEY: "custom/key.db" })).toBe( + "custom/key.db" + ); + }); + it("covers WAL sidecars", () => { + expect(dbSyncFiles("/data/dev.db")).toEqual([ + "/data/dev.db", + "/data/dev.db-wal", + "/data/dev.db-shm", + ]); + }); +}); + +describe("objectUrl", () => { + it("builds virtual-hosted URL when pathStyle is false", () => { + expect(objectUrl(CFG, "db/dev.db")).toBe( + "https://jobsync-db-xyz.t3.storageapi.dev/db/dev.db" + ); + }); + it("builds path-style URL when pathStyle is true", () => { + expect(objectUrl({ ...CFG, pathStyle: true }, "db/dev.db")).toBe( + "https://t3.storageapi.dev/jobsync-db-xyz/db/dev.db" + ); + }); +}); + +describe("signS3Request", () => { + const now = new Date("2026-09-17T12:00:00.000Z"); + it("is deterministic and well-formed", () => { + const a = signS3Request({ method: "GET", url: objectUrl(CFG, "db/dev.db"), cfg: CFG, now }); + const b = signS3Request({ method: "GET", url: objectUrl(CFG, "db/dev.db"), cfg: CFG, now }); + expect(a).toEqual(b); + expect(a.Authorization).toMatch( + /^AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE\/20260917\/auto\/s3\/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=[0-9a-f]{64}$/ + ); + expect(a["x-amz-date"]).toBe("20260917T120000Z"); + }); + it("varies with method", () => { + const get = signS3Request({ method: "GET", url: objectUrl(CFG, "db/dev.db"), cfg: CFG, now }); + const put = signS3Request({ method: "PUT", url: objectUrl(CFG, "db/dev.db"), cfg: CFG, now }); + expect(get.Authorization).not.toBe(put.Authorization); + }); +}); + +function mockFetchOnce(status: number, body?: BodyInit) { + const calls: { url: string; method: string; headers: Record }[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init: RequestInit) => { + calls.push({ url, method: init.method ?? "GET", headers: init.headers as Record }); + return new Response(body ?? null, { status }); + }) + ); + return calls; +} + +describe("s3 operations (mocked fetch)", () => { + it("s3Head true on 200, false on 404", async () => { + mockFetchOnce(200); + expect(await s3Head(CFG, "db/dev.db")).toBe(true); + mockFetchOnce(404); + expect(await s3Head(CFG, "db/dev.db")).toBe(false); + }); + + it("s3Get writes file on 200, returns false on 404", async () => { + const dir = mkdtempSync(join(tmpdir(), "s3get-")); + mockFetchOnce(200, "DB-BYTES"); + const dest = join(dir, "dev.db"); + expect(await s3Get(CFG, "db/dev.db", dest)).toBe(true); + expect(readFileSync(dest, "utf8")).toBe("DB-BYTES"); + mockFetchOnce(404); + expect(await s3Get(CFG, "db/missing.db", join(dir, "missing.db"))).toBe(false); + }); + + it("s3Put sends PUT to the object URL", async () => { + const dir = mkdtempSync(join(tmpdir(), "s3put-")); + const src = join(dir, "dev.db"); + const { writeFileSync } = await import("node:fs"); + writeFileSync(src, "DATA"); + const calls = mockFetchOnce(200); + expect(await s3Put(CFG, "db/dev.db", src)).toBe(true); + expect(calls[0].method).toBe("PUT"); + expect(calls[0].url).toBe("https://jobsync-db-xyz.t3.storageapi.dev/db/dev.db"); + expect(calls[0].headers.Authorization).toMatch(/^AWS4-HMAC-SHA256 /); + }); + + it("downloadDb restores main file; uploadDb skips missing sidecars", async () => { + const dir = mkdtempSync(join(tmpdir(), "sync-")); + const db = join(dir, "app.db"); + const { writeFileSync } = await import("node:fs"); + // download: only main exists remotely — fetch mock answers per URL + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + if (String(url).endsWith("app.db")) return new Response("REMOTE", { status: 200 }); + return new Response(null, { status: 404 }); + }) + ); + expect(await downloadDb(CFG, db)).toBe(true); + expect(readFileSync(db, "utf8")).toBe("REMOTE"); + + // upload: only main exists locally + const puts: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init: RequestInit) => { + if (init.method === "PUT") puts.push(String(url)); + return new Response(null, { status: 200 }); + }) + ); + writeFileSync(db, "LOCAL"); + expect(await uploadDb(CFG, db)).toBe(true); + expect(puts).toHaveLength(1); + expect(existsSync(`${db}-wal`)).toBe(false); + }); +}); diff --git a/__tests__/outreach-follow-up.spec.ts b/__tests__/outreach-follow-up.spec.ts new file mode 100644 index 000000000..f70309198 --- /dev/null +++ b/__tests__/outreach-follow-up.spec.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from "vitest"; +import { + followUpDueDate, + isFollowUpDue, + FOLLOW_UP_MIN_DAYS, + FOLLOW_UP_MAX_DAYS, +} from "@/lib/outreach/follow-up"; + +describe("outreach follow-up rule", () => { + it("defaults mid-window (12 days after sending)", () => { + const sent = new Date("2026-09-17T00:00:00Z"); + const due = followUpDueDate(sent); + const days = (due.getTime() - sent.getTime()) / 86_400_000; + expect(days).toBeGreaterThanOrEqual(FOLLOW_UP_MIN_DAYS); + expect(days).toBeLessThanOrEqual(FOLLOW_UP_MAX_DAYS); + expect(due.toISOString()).toBe("2026-09-29T00:00:00.000Z"); + }); + + it("flags overdue follow-ups", () => { + expect(isFollowUpDue(new Date("2026-09-01"), new Date("2026-09-17"))).toBe(true); + expect(isFollowUpDue(new Date("2026-10-01"), new Date("2026-09-17"))).toBe(false); + expect(isFollowUpDue(null)).toBe(false); + }); +}); diff --git a/__tests__/outreach.actions.spec.ts b/__tests__/outreach.actions.spec.ts new file mode 100644 index 000000000..d9216a0b6 --- /dev/null +++ b/__tests__/outreach.actions.spec.ts @@ -0,0 +1,62 @@ +import { logOutreach, getFollowUpsDue, markOutreachReplied } from "@/actions/outreach/outreach.actions"; +import { getCurrentUser } from "@/utils/user.utils"; +import { PrismaClient } from "@prisma/client"; + +vi.mock("@prisma/client", () => { + const mPrismaClient = { + job: { count: vi.fn() }, + outreach: { create: vi.fn(), findMany: vi.fn(), update: vi.fn() }, + }; + return { PrismaClient: vi.fn(function () { return mPrismaClient; }) }; +}); +vi.mock("@/utils/user.utils", () => ({ getCurrentUser: vi.fn() })); + +const prisma = new PrismaClient() as unknown as { + job: { count: ReturnType }; + outreach: { + create: ReturnType; + findMany: ReturnType; + update: ReturnType; + }; +}; +const mockUser = vi.mocked(getCurrentUser); + +describe("outreach actions", () => { + beforeEach(() => mockUser.mockResolvedValue({ id: "u1" } as never)); + + it("auto-sets followUpDue ~12 days after sentAt", async () => { + prisma.job.count.mockResolvedValue(1); + prisma.outreach.create.mockImplementation(async (args: any) => ({ id: "o1", ...args.data })); + const sentAt = new Date("2026-09-17T00:00:00Z"); + const res = (await logOutreach({ jobId: "j1", sentAt })) as { + success: boolean; + data: { followUpDue: Date }; + }; + expect(res.success).toBe(true); + expect(res.data.followUpDue.toISOString()).toBe("2026-09-29T00:00:00.000Z"); + }); + + it("requires a link and ownership", async () => { + expect((await logOutreach({})).success).not.toBe(true); + prisma.job.count.mockResolvedValue(0); + expect((await logOutreach({ jobId: "nope" })).success).not.toBe(true); + }); + + it("getFollowUpsDue only returns unreplied, due items for the user", async () => { + prisma.outreach.findMany.mockResolvedValue([]); + await getFollowUpsDue(new Date("2026-09-30")); + expect(prisma.outreach.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ repliedAt: null, job: { userId: "u1" } }), + }) + ); + }); + + it("markOutreachReplied stamps repliedAt + outcome", async () => { + prisma.outreach.update.mockResolvedValue({ id: "o1" }); + const res = await markOutreachReplied("o1", "meeting"); + expect(res.success).toBe(true); + expect(prisma.outreach.update.mock.calls[0][0].data).toMatchObject({ outcome: "meeting" }); + expect(prisma.outreach.update.mock.calls[0][0].data.repliedAt).toBeInstanceOf(Date); + }); +}); diff --git a/__tests__/workspace.actions.spec.ts b/__tests__/workspace.actions.spec.ts new file mode 100644 index 000000000..bf9fb8d87 --- /dev/null +++ b/__tests__/workspace.actions.spec.ts @@ -0,0 +1,55 @@ +import { getWorkspaces, createWorkspace, ensureDefaultWorkspaces } from "@/actions/workspace/workspace.actions"; +import { getCurrentUser } from "@/utils/user.utils"; +import { PrismaClient } from "@prisma/client"; + +vi.mock("@prisma/client", () => { + const mPrismaClient = { + workspace: { findMany: vi.fn(), create: vi.fn(), delete: vi.fn() }, + job: { count: vi.fn() }, + }; + return { PrismaClient: vi.fn(function () { return mPrismaClient; }) }; +}); +vi.mock("@/utils/user.utils", () => ({ getCurrentUser: vi.fn() })); + +const prisma = new PrismaClient() as unknown as { + workspace: { findMany: ReturnType; create: ReturnType }; +}; +const mockUser = vi.mocked(getCurrentUser); + +describe("workspace actions", () => { + beforeEach(() => mockUser.mockResolvedValue({ id: "u1" } as never)); + + it("creates a SCHOOL workspace with 6 default stages", async () => { + prisma.workspace.create.mockResolvedValue({ id: "w1" }); + const res = await createWorkspace("School", "SCHOOL"); + expect(res.success).toBe(true); + const payload = prisma.workspace.create.mock.calls[0][0]; + expect(payload.data.stages.create).toHaveLength(6); + expect(payload.data.stages.create[0]).toMatchObject({ name: "Researching", order: 1 }); + }); + + it("rejects empty names and bad types", async () => { + expect((await createWorkspace(" ", "SCHOOL")).success).not.toBe(true); + expect((await createWorkspace("X", "PHD" as never)).success).not.toBe(true); + }); + + it("ensureDefaultWorkspaces only creates what is missing", async () => { + prisma.workspace.findMany.mockResolvedValue([{ id: "w0", type: "JOB" }]); + prisma.workspace.create.mockResolvedValue({ id: "w1", type: "SCHOOL" }); + const res = (await ensureDefaultWorkspaces()) as { + success: boolean; + data: { id: string; type: string }[]; + }; + expect(prisma.workspace.create).toHaveBeenCalledTimes(1); + expect(prisma.workspace.create.mock.calls[0][0].data.type).toBe("SCHOOL"); + expect(res.data).toHaveLength(2); + }); + + it("getWorkspaces scopes to the user", async () => { + prisma.workspace.findMany.mockResolvedValue([]); + await getWorkspaces(); + expect(prisma.workspace.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { userId: "u1" } }) + ); + }); +}); diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 647112fa7..c24180fa2 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -7,9 +7,23 @@ if [ -z "$AUTH_SECRET" ]; then echo "AUTH_SECRET was not set — generated a temporary secret for this container." fi +# DB lives on a Railway Bucket (S3), not a Volume: restore before boot. +node /app/scripts/sync-db-bucket.mjs download || echo "bucket download skipped — fresh database." + # Run migrations as root (before switching users) npx -y prisma@6.19.0 migrate deploy +# Periodically push the DB file back to the bucket (crash safety), +# plus a final push on shutdown. +( + while true; do + sleep 300 + node /app/scripts/sync-db-bucket.mjs upload || true + done +) & +UPLOADER_PID=$! +trap 'kill $UPLOADER_PID 2>/dev/null; node /app/scripts/sync-db-bucket.mjs upload || true' TERM INT + # Fix /data permissions and run app as nextjs user chown -R nextjs:nodejs /data export HOME=/home/nextjs diff --git a/prisma/migrations/20260917010308_dual_workspace_outreach/migration.sql b/prisma/migrations/20260917010308_dual_workspace_outreach/migration.sql new file mode 100644 index 000000000..fe29ee83c --- /dev/null +++ b/prisma/migrations/20260917010308_dual_workspace_outreach/migration.sql @@ -0,0 +1,119 @@ +-- CreateTable +CREATE TABLE "Workspace" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "type" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Workspace_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Stage" ( + "id" TEXT NOT NULL PRIMARY KEY, + "workspaceId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "order" INTEGER NOT NULL, + "isTerminal" BOOLEAN NOT NULL DEFAULT false, + "color" TEXT, + CONSTRAINT "Stage_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "WorkspaceField" ( + "id" TEXT NOT NULL PRIMARY KEY, + "workspaceId" TEXT NOT NULL, + "key" TEXT NOT NULL, + "label" TEXT NOT NULL, + "type" TEXT NOT NULL, + "options" TEXT, + CONSTRAINT "WorkspaceField_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Outreach" ( + "id" TEXT NOT NULL PRIMARY KEY, + "jobId" TEXT, + "contactId" TEXT, + "channel" TEXT NOT NULL DEFAULT 'email', + "subject" TEXT, + "body" TEXT, + "paperCited" TEXT, + "sentAt" DATETIME, + "followUpDue" DATETIME, + "repliedAt" DATETIME, + "outcome" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Outreach_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "Job" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "Outreach_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "Contact" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Job" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "jobUrl" TEXT, + "description" TEXT NOT NULL, + "jobType" TEXT NOT NULL, + "workplaceType" TEXT, + "createdAt" DATETIME NOT NULL, + "applied" BOOLEAN NOT NULL DEFAULT false, + "appliedDate" DATETIME, + "dueDate" DATETIME, + "statusId" TEXT NOT NULL, + "jobTitleId" TEXT NOT NULL, + "companyId" TEXT NOT NULL, + "jobSourceId" TEXT, + "salaryRange" TEXT, + "locationId" TEXT, + "resumeId" TEXT, + "coverLetterId" TEXT, + "workspaceId" TEXT, + "automationId" TEXT, + "matchScore" INTEGER, + "matchData" TEXT, + "discoveryStatus" TEXT, + "discoveredAt" DATETIME, + "createdVia" TEXT, + "descriptionCompleteness" TEXT, + CONSTRAINT "Job_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "Job_statusId_fkey" FOREIGN KEY ("statusId") REFERENCES "JobStatus" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "Job_jobTitleId_fkey" FOREIGN KEY ("jobTitleId") REFERENCES "JobTitle" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "Job_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "Job_jobSourceId_fkey" FOREIGN KEY ("jobSourceId") REFERENCES "JobSource" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "Job_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "Job_resumeId_fkey" FOREIGN KEY ("resumeId") REFERENCES "Resume" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "Job_coverLetterId_fkey" FOREIGN KEY ("coverLetterId") REFERENCES "CoverLetter" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "Job_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "Job_automationId_fkey" FOREIGN KEY ("automationId") REFERENCES "Automation" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_Job" ("applied", "appliedDate", "automationId", "companyId", "coverLetterId", "createdAt", "createdVia", "description", "descriptionCompleteness", "discoveredAt", "discoveryStatus", "dueDate", "id", "jobSourceId", "jobTitleId", "jobType", "jobUrl", "locationId", "matchData", "matchScore", "resumeId", "salaryRange", "statusId", "userId", "workplaceType") SELECT "applied", "appliedDate", "automationId", "companyId", "coverLetterId", "createdAt", "createdVia", "description", "descriptionCompleteness", "discoveredAt", "discoveryStatus", "dueDate", "id", "jobSourceId", "jobTitleId", "jobType", "jobUrl", "locationId", "matchData", "matchScore", "resumeId", "salaryRange", "statusId", "userId", "workplaceType" FROM "Job"; +DROP TABLE "Job"; +ALTER TABLE "new_Job" RENAME TO "Job"; +CREATE INDEX "Job_userId_automationId_idx" ON "Job"("userId", "automationId"); +CREATE INDEX "Job_userId_discoveryStatus_idx" ON "Job"("userId", "discoveryStatus"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; + +-- CreateIndex +CREATE INDEX "Workspace_userId_idx" ON "Workspace"("userId"); + +-- CreateIndex +CREATE INDEX "Workspace_userId_type_idx" ON "Workspace"("userId", "type"); + +-- CreateIndex +CREATE INDEX "Stage_workspaceId_order_idx" ON "Stage"("workspaceId", "order"); + +-- CreateIndex +CREATE UNIQUE INDEX "WorkspaceField_workspaceId_key_key" ON "WorkspaceField"("workspaceId", "key"); + +-- CreateIndex +CREATE INDEX "Outreach_jobId_idx" ON "Outreach"("jobId"); + +-- CreateIndex +CREATE INDEX "Outreach_contactId_idx" ON "Outreach"("contactId"); + +-- CreateIndex +CREATE INDEX "Outreach_followUpDue_idx" ON "Outreach"("followUpDue"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 17c44665a..c040c7acc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -39,6 +39,7 @@ model User { ChatConversation ChatConversation? defaultResumeId String? defaultResume Resume? @relation("UserDefaultResume", fields: [defaultResumeId], references: [id], onDelete: SetNull) + workspaces Workspace[] } model ApiKey { @@ -246,6 +247,8 @@ model Contact { jobLinks JobContact[] + outreach Outreach[] + @@index([createdBy]) @@index([companyId]) @@index([workedAtCompanyId]) @@ -396,6 +399,11 @@ model Job { tags Tag[] contactLinks JobContact[] + // Dual-workspace (PRD Phase 1): optional link into a SCHOOL/JOB workspace. + workspaceId String? + workspace Workspace? @relation(fields: [workspaceId], references: [id], onDelete: SetNull) + outreach Outreach[] + // Automation discovery fields automationId String? automation Automation? @relation(fields: [automationId], references: [id], onDelete: SetNull) @@ -620,3 +628,72 @@ model ChatConversation { updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) } + +// ---------- Dual-workspace (School + Job) — PRD Phase 1 ---------- + +enum WorkspaceType { + SCHOOL + JOB +} + +model Workspace { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + name String + type WorkspaceType + createdAt DateTime @default(now()) + stages Stage[] + jobs Job[] + fields WorkspaceField[] + + @@index([userId]) + @@index([userId, type]) +} + +model Stage { + id String @id @default(uuid()) + workspaceId String + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + name String + order Int + isTerminal Boolean @default(false) + color String? + + @@index([workspaceId, order]) +} + +model WorkspaceField { + id String @id @default(uuid()) + workspaceId String + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + key String + label String + type String // text | number | date | select | url + options String? // JSON for select + + @@unique([workspaceId, key]) +} + +// Cold-email lifecycle: one row per professor/recruiter contacted. +// Follow-up rule: sentAt + 10–14 days. +model Outreach { + id String @id @default(uuid()) + jobId String? + job Job? @relation(fields: [jobId], references: [id], onDelete: Cascade) + contactId String? + contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull) + channel String @default("email") + subject String? + body String? + paperCited String? + sentAt DateTime? + followUpDue DateTime? + repliedAt DateTime? + outcome String? // no_reply | positive | negative | meeting | offer + createdAt DateTime @default(now()) + + @@index([jobId]) + @@index([contactId]) + @@index([followUpDue]) +} diff --git a/scripts/seed-school-workspace.mjs b/scripts/seed-school-workspace.mjs new file mode 100644 index 000000000..c73202153 --- /dev/null +++ b/scripts/seed-school-workspace.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +/** + * Seed the SCHOOL workspace + PI directory for Samuel's 8 live PhD targets. + * Idempotent (skips existing workspace / contacts by name). + * + * Usage: + * DATABASE_URL="file:./dev.db" node scripts/seed-school-workspace.mjs --email=you@example.com + * + * Details (deadlines, paper angles) live in D:/my apps/reclip/{CAS-cold-emails,other-cold-emails}. + */ + +import { PrismaClient } from "@prisma/client"; + +const TARGETS = [ + { name: "Prof. Sarthak Misra", org: "University of Twente", deadline: "2026-10-01", angle: "surgical micro-robotics" }, + { name: "Prof. Dong", org: "Vanderbilt University", deadline: "2026-10-15", angle: "surgical / magnetic robots" }, + { name: "Prof. Emmanuel Vander Poorten", org: "KU Leuven", deadline: "2026-10-30", angle: "robot-assisted catheterization (RAS group head)" }, + { name: "Prof. Hao Su", org: "New York University", deadline: "2026-12-01", angle: "surgical robotics + wearable robotics" }, + { name: "Prof. Stefanie Speidel", org: "TU Dresden / NCT", deadline: "rolling", angle: "computer-assisted surgery, shared autonomy" }, + { name: "Dr. Jake George", org: "University of Utah", deadline: "before 2027-01-01", angle: "neurorobotics / neural interfaces" }, + { name: "Dr. Shepherd", org: "Northeastern University", deadline: "any time", angle: "wearable robotics" }, +]; + +const STAGES = ["Researching", "Outreach sent", "Applied", "Interview", "Offer", "Rejected / Withdrawn"]; + +const email = (process.argv.find((a) => a.startsWith("--email=")) ?? "").slice("--email=".length); +if (!email) { + console.error("usage: node scripts/seed-school-workspace.mjs --email=you@example.com"); + process.exit(2); +} + +const prisma = new PrismaClient(); +try { + const user = await prisma.user.findUnique({ where: { email } }); + if (!user) throw new Error(`no user with email ${email}`); + + let ws = await prisma.workspace.findFirst({ where: { userId: user.id, type: "SCHOOL" } }); + if (!ws) { + ws = await prisma.workspace.create({ + data: { + userId: user.id, + name: "School", + type: "SCHOOL", + stages: { create: STAGES.map((name, i) => ({ name, order: i + 1, isTerminal: name.startsWith("Rejected") })) }, + }, + }); + console.log(`created SCHOOL workspace + ${STAGES.length} stages`); + } else { + console.log("SCHOOL workspace already exists — skipping"); + } + + for (const t of TARGETS) { + const exists = await prisma.contact.findFirst({ where: { createdBy: user.id, name: t.name } }); + if (exists) { + console.log(`contact exists: ${t.name} — skipping`); + continue; + } + await prisma.contact.create({ + data: { + createdBy: user.id, + name: t.name, + title: "Professor / PI", + relationship: "PhD supervisor prospect", + notes: `${t.org} — ${t.angle}. Deadline: ${t.deadline}. See cold-email drafts in CAS-cold-emails / other-cold-emails.`, + }, + }); + console.log(`created contact: ${t.name}`); + } +} finally { + await prisma.$disconnect(); +} diff --git a/scripts/sync-db-bucket.mjs b/scripts/sync-db-bucket.mjs new file mode 100644 index 000000000..5cfbbfae3 --- /dev/null +++ b/scripts/sync-db-bucket.mjs @@ -0,0 +1,199 @@ +#!/usr/bin/env node +/** + * Sync the single-file database (SQLite, Prisma-compatible) with a + * Railway Bucket (S3-compatible object storage). + * + * Why not DuckDB as the engine: Prisma — the app's entire data layer + * (100+ call sites) — has no DuckDB provider. Swapping engines means + * rewriting every query. So the transactional DB stays SQLite-on-Prisma, + * and durability moves from a Railway Volume to a Railway Bucket: + * download on boot, upload on a timer + on shutdown. + * `ponytail: file-level sync of main+wal+shm; per-row replication if multi-writer ever needed` + * + * Usage: + * node scripts/sync-db-bucket.mjs download [dbPath] + * node scripts/sync-db-bucket.mjs upload [dbPath] + * + * Env (Railway Bucket credentials tab / variable references): + * AWS_ENDPOINT_URL | S3_ENDPOINT e.g. https://t3.storageapi.dev (required) + * AWS_ACCESS_KEY_ID | S3_ACCESS_KEY_ID (required) + * AWS_SECRET_ACCESS_KEY | S3_SECRET_ACCESS_KEY (required) + * AWS_S3_BUCKET_NAME | BUCKET_NAME | S3_BUCKET (required) + * AWS_DEFAULT_REGION | S3_REGION default "auto" + * S3_PATH_STYLE default "true" (set "false" for virtual-hosted style) + * DB_OBJECT_KEY default "db/" + * DATABASE_URL e.g. file:/data/dev.db (db path source) + * + * Exit 0 + skip message when the bucket is not configured (local dev). + */ + +import { createHash, createHmac } from "node:crypto"; +import { mkdirSync, existsSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, basename } from "node:path"; + +const EMPTY_HASH = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + +/** @param {Record} env */ +export function pick(env, ...names) { + for (const n of names) { + const v = env[n]; + if (v !== undefined && v !== null && String(v).trim() !== "") return String(v).trim(); + } + return undefined; +} + +/** @param {Record} [env] */ +export function getBucketConfig(env = process.env) { + const endpoint = pick(env, "AWS_ENDPOINT_URL", "S3_ENDPOINT", "BUCKET_ENDPOINT"); + const accessKeyId = pick(env, "AWS_ACCESS_KEY_ID", "S3_ACCESS_KEY_ID", "BUCKET_ACCESS_KEY_ID"); + const secretAccessKey = pick(env, "AWS_SECRET_ACCESS_KEY", "S3_SECRET_ACCESS_KEY", "BUCKET_SECRET_ACCESS_KEY"); + const bucket = pick(env, "AWS_S3_BUCKET_NAME", "BUCKET_NAME", "S3_BUCKET", "S3_BUCKET_NAME"); + const region = pick(env, "AWS_DEFAULT_REGION", "S3_REGION", "BUCKET_REGION") ?? "auto"; + const pathStyle = (pick(env, "S3_PATH_STYLE") ?? "true").toLowerCase() !== "false"; + const configured = Boolean(endpoint && accessKeyId && secretAccessKey && bucket); + return { endpoint, accessKeyId, secretAccessKey, bucket, region, pathStyle, configured }; +} + +export function resolveDbPath(databaseUrl, fallback = "/data/dev.db") { + if (!databaseUrl) return fallback; + const u = String(databaseUrl).trim(); + if (u.startsWith("file:")) return u.slice("file:".length) || fallback; + return u || fallback; +} + +/** @param {Record} [env] */ +export function dbObjectKey(dbPath, env = process.env) { + return pick(env, "DB_OBJECT_KEY") ?? `db/${basename(dbPath)}`; +} + +/** Files that make up one SQLite database (WAL mode = up to 3 files). */ +export function dbSyncFiles(dbPath) { + return [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]; +} + +function encodeKey(key) { + return key.split("/").map((s) => encodeURIComponent(s)).join("/"); +} + +export function objectUrl(cfg, key) { + const base = cfg.endpoint.replace(/\/+$/, ""); + return cfg.pathStyle + ? `${base}/${cfg.bucket}/${encodeKey(key)}` + : `${base.replace("://", `://${cfg.bucket}.`)}/${encodeKey(key)}`; +} + +function amzDates(now = new Date()) { + const iso = now.toISOString().replace(/[-:]|\.\d{3}/g, ""); + return { amzDate: iso, dateStamp: iso.slice(0, 8) }; +} + +function signingKey(secret, dateStamp, region) { + const kDate = createHmac("sha256", `AWS4${secret}`).update(dateStamp).digest(); + const kRegion = createHmac("sha256", kDate).update(region).digest(); + const kService = createHmac("sha256", kRegion).update("s3").digest(); + return createHmac("sha256", kService).update("aws4_request").digest(); +} + +export function signS3Request({ method, url, payloadHash = EMPTY_HASH, cfg, now = new Date() }) { + const { amzDate, dateStamp } = amzDates(now); + const u = new URL(url); + const canonicalUri = u.pathname; + const canonicalQuery = u.searchParams.toString(); + const signedHeaders = "host;x-amz-content-sha256;x-amz-date"; + const canonical = + `${method}\n${canonicalUri}\n${canonicalQuery}\n` + + `host:${u.host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n\n` + + `${signedHeaders}\n${payloadHash}`; + const scope = `${dateStamp}/${cfg.region}/s3/aws4_request`; + const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${scope}\n${createHash("sha256").update(canonical).digest("hex")}`; + const signature = createHmac("sha256", signingKey(cfg.secretAccessKey, dateStamp, cfg.region)) + .update(stringToSign) + .digest("hex"); + return { + Authorization: `AWS4-HMAC-SHA256 Credential=${cfg.accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`, + "x-amz-date": amzDate, + "x-amz-content-sha256": payloadHash, + }; +} + +async function s3Fetch(cfg, method, key, body) { + const url = objectUrl(cfg, key); + const payloadHash = body ? createHash("sha256").update(body).digest("hex") : EMPTY_HASH; + const headers = { + ...signS3Request({ method, url, payloadHash, cfg }), + ...(body ? { "Content-Type": "application/octet-stream", "Content-Length": String(body.length) } : {}), + }; + return fetch(url, { method, headers, body }); +} + +export async function s3Head(cfg, key) { + const res = await s3Fetch(cfg, "HEAD", key); + if (res.status === 200) return true; + if (res.status === 404) return false; + throw new Error(`S3 HEAD ${key} -> HTTP ${res.status}`); +} + +export async function s3Get(cfg, key, destPath) { + const res = await s3Fetch(cfg, "GET", key); + if (res.status === 404) return false; + if (!res.ok) throw new Error(`S3 GET ${key} -> HTTP ${res.status}`); + mkdirSync(dirname(destPath), { recursive: true }); + writeFileSync(destPath, Buffer.from(await res.arrayBuffer())); + return true; +} + +export async function s3Put(cfg, key, srcPath) { + const body = readFileSync(srcPath); + const res = await s3Fetch(cfg, "PUT", key, body); + if (!res.ok) throw new Error(`S3 PUT ${key} -> HTTP ${res.status}`); + return true; +} + +export async function downloadDb(cfg, dbPath) { + mkdirSync(dirname(dbPath), { recursive: true }); + let restored = false; + for (const f of dbSyncFiles(dbPath)) { + const key = dbObjectKey(f); + if (await s3Get(cfg, key, f)) restored = true; + } + return restored; +} + +export async function uploadDb(cfg, dbPath) { + let uploaded = false; + for (const f of dbSyncFiles(dbPath)) { + if (!existsSync(f)) continue; + await s3Put(cfg, dbObjectKey(f), f); + uploaded = true; + } + return uploaded; +} + +// --- CLI --- +const isMain = process.argv[1] && basename(process.argv[1]) === "sync-db-bucket.mjs"; +if (isMain) { + const mode = process.argv[2]; + const cfg = getBucketConfig(); + const dbPath = resolveDbPath(process.env.DATABASE_URL, process.argv[3] ?? "/data/dev.db"); + if (!cfg.configured) { + console.log("bucket-sync: S3 bucket not configured — skipping."); + process.exit(0); + } + if (mode !== "download" && mode !== "upload") { + console.error("usage: node scripts/sync-db-bucket.mjs download|upload [dbPath]"); + process.exit(2); + } + try { + if (mode === "download") { + const restored = await downloadDb(cfg, dbPath); + console.log(restored ? `bucket-sync: restored ${dbPath} from bucket.` : "bucket-sync: no object in bucket — fresh database."); + } else { + const ok = await uploadDb(cfg, dbPath); + console.log(ok ? `bucket-sync: uploaded ${dbPath} to bucket.` : "bucket-sync: no local db file — nothing uploaded."); + } + } catch (err) { + console.error(`bucket-sync: ${mode} failed: ${err.message}`); + process.exit(1); + } +} diff --git a/src/actions/outreach/outreach.actions.ts b/src/actions/outreach/outreach.actions.ts new file mode 100644 index 000000000..08f4ecf8e --- /dev/null +++ b/src/actions/outreach/outreach.actions.ts @@ -0,0 +1,68 @@ +"use server"; +import prisma from "@/lib/db"; +import { handleError } from "@/lib/utils"; +import { requireUser } from "../shared"; +import { followUpDueDate } from "@/lib/outreach/follow-up"; + +export const logOutreach = async (input: { + jobId?: string; + contactId?: string; + subject?: string; + body?: string; + paperCited?: string; + sentAt?: Date; +}) => { + try { + const user = await requireUser(); + if (!input.jobId && !input.contactId) throw new Error("Link the outreach to an application or a contact."); + if (input.jobId) { + const own = await prisma.job.count({ where: { id: input.jobId, userId: user.id } }); + if (!own) throw new Error("Application not found."); + } + const sentAt = input.sentAt ?? new Date(); + const row = await prisma.outreach.create({ + data: { + jobId: input.jobId, + contactId: input.contactId, + subject: input.subject, + body: input.body, + paperCited: input.paperCited, + sentAt, + followUpDue: followUpDueDate(sentAt), + }, + }); + return { data: row, success: true }; + } catch (error) { + return handleError(error, "Failed to log outreach. "); + } +}; + +export const getFollowUpsDue = async (now = new Date()) => { + try { + const user = await requireUser(); + return await prisma.outreach.findMany({ + where: { + repliedAt: null, + followUpDue: { lte: now }, + job: { userId: user.id }, + }, + include: { job: { select: { id: true, description: true } }, contact: true }, + orderBy: { followUpDue: "asc" }, + }); + } catch (error) { + return handleError(error, "Failed to fetch follow-ups. "); + } +}; + +export const markOutreachReplied = async (outreachId: string, outcome = "positive") => { + try { + await requireUser(); + const row = await prisma.outreach.update({ + where: { id: outreachId }, + data: { repliedAt: new Date(), outcome }, + }); + return { data: row, success: true }; + } catch (error) { + return handleError(error, "Failed to update outreach. "); + } +}; diff --git a/src/actions/workspace/workspace.actions.ts b/src/actions/workspace/workspace.actions.ts new file mode 100644 index 000000000..27adc5d51 --- /dev/null +++ b/src/actions/workspace/workspace.actions.ts @@ -0,0 +1,72 @@ +"use server"; +import prisma from "@/lib/db"; +import { handleError } from "@/lib/utils"; +import { requireUser } from "../shared"; +import { defaultStagesFor } from "@/lib/workspaces/defaults"; +import type { WorkspaceType } from "@prisma/client"; + +export const getWorkspaces = async () => { + try { + const user = await requireUser(); + return await prisma.workspace.findMany({ + where: { userId: user.id }, + include: { stages: { orderBy: { order: "asc" } } }, + orderBy: { createdAt: "asc" }, + }); + } catch (error) { + return handleError(error, "Failed to fetch workspaces. "); + } +}; + +export const createWorkspace = async (name: string, type: WorkspaceType) => { + try { + const user = await requireUser(); + if (!name.trim()) throw new Error("Workspace name cannot be empty."); + if (type !== "SCHOOL" && type !== "JOB") throw new Error("Unknown workspace type."); + const ws = await prisma.workspace.create({ + data: { + userId: user.id, + name: name.trim(), + type, + stages: { create: defaultStagesFor(type) }, + }, + include: { stages: true }, + }); + return { data: ws, success: true }; + } catch (error) { + return handleError(error, "Failed to create workspace. "); + } +}; + +// Idempotent: creates SCHOOL + JOB workspaces (with default stages) only if missing. +export const ensureDefaultWorkspaces = async () => { + try { + const user = await requireUser(); + const existing = await prisma.workspace.findMany({ where: { userId: user.id } }); + const types = new Set(existing.map((w) => w.type)); + const created = []; + for (const [type, name] of [["SCHOOL", "School"], ["JOB", "Jobs"]] as const) { + if (types.has(type)) continue; + created.push( + await prisma.workspace.create({ + data: { userId: user.id, name, type, stages: { create: defaultStagesFor(type) } }, + }) + ); + } + return { data: [...existing, ...created], success: true }; + } catch (error) { + return handleError(error, "Failed to ensure workspaces. "); + } +}; + +export const deleteWorkspaceById = async (workspaceId: string) => { + try { + const user = await requireUser(); + const linked = await prisma.job.count({ where: { workspaceId, User: { id: user.id } } }); + if (linked > 0) throw new Error(`Workspace still has ${linked} application(s) linked.`); + const res = await prisma.workspace.delete({ where: { id: workspaceId, userId: user.id } }); + return { res, success: true }; + } catch (error) { + return handleError(error, "Failed to delete workspace. "); + } +}; diff --git a/src/components/osui/workspace-switcher.tsx b/src/components/osui/workspace-switcher.tsx new file mode 100644 index 000000000..662c53641 --- /dev/null +++ b/src/components/osui/workspace-switcher.tsx @@ -0,0 +1,48 @@ +"use client"; + +// osui: workspace-switcher (paper/ink, light-first). +// Props-driven and dependency-free apart from React + Tailwind. + +export type OsuiWorkspace = { + id: string; + name: string; + type: "SCHOOL" | "JOB"; +}; + +export function WorkspaceSwitcher({ + workspaces, + activeId, + onSelect, +}: { + workspaces: OsuiWorkspace[]; + activeId: string | null; + onSelect: (id: string) => void; +}) { + return ( +
+ {workspaces.map((w) => { + const active = w.id === activeId; + return ( + + ); + })} +
+ ); +} diff --git a/src/lib/outreach/follow-up.ts b/src/lib/outreach/follow-up.ts new file mode 100644 index 000000000..e25caf45b --- /dev/null +++ b/src/lib/outreach/follow-up.ts @@ -0,0 +1,17 @@ +// Follow-up rule for cold outreach: one follow-up, 10–14 days after sending. +// Default lands mid-window (12 days). + +export const FOLLOW_UP_MIN_DAYS = 10; +export const FOLLOW_UP_MAX_DAYS = 14; +export const FOLLOW_UP_DEFAULT_DAYS = 12; + +export function followUpDueDate(sentAt: Date, days = FOLLOW_UP_DEFAULT_DAYS): Date { + const d = new Date(sentAt); + d.setDate(d.getDate() + days); + return d; +} + +export function isFollowUpDue(followUpDue: Date | null | undefined, now = new Date()): boolean { + if (!followUpDue) return false; + return followUpDue <= now; +} diff --git a/src/lib/workspaces/defaults.ts b/src/lib/workspaces/defaults.ts new file mode 100644 index 000000000..2cb755728 --- /dev/null +++ b/src/lib/workspaces/defaults.ts @@ -0,0 +1,25 @@ +import type { WorkspaceType } from "@prisma/client"; + +// Default pipeline stages per workspace type (PRD §4). Editable in Settings later. +export const DEFAULT_STAGES: Record = { + SCHOOL: [ + { name: "Researching", color: "#64748b" }, + { name: "Outreach sent", color: "#0284c7" }, + { name: "Applied", color: "#7c3aed" }, + { name: "Interview", color: "#d97706" }, + { name: "Offer", color: "#059669" }, + { name: "Rejected / Withdrawn", isTerminal: true, color: "#dc2626" }, + ], + JOB: [ + { name: "Saved", color: "#64748b" }, + { name: "Applied", color: "#0284c7" }, + { name: "Screening", color: "#7c3aed" }, + { name: "Interview", color: "#d97706" }, + { name: "Offer", color: "#059669" }, + { name: "Rejected / Withdrawn", isTerminal: true, color: "#dc2626" }, + ], +}; + +export function defaultStagesFor(type: WorkspaceType) { + return DEFAULT_STAGES[type].map((s, i) => ({ ...s, order: i + 1 })); +} From dd5ccf132896847fdf8722a4ede1cb58786e37bf Mon Sep 17 00:00:00 2001 From: Futuregh Date: Thu, 17 Sep 2026 01:14:43 +0000 Subject: [PATCH 2/7] Fix entrypoint exec killing bucket uploader; add S3 delete + test --- __tests__/bucket-sync.spec.ts | 9 +++++++++ docker-entrypoint.sh | 9 ++++++--- scripts/sync-db-bucket.mjs | 15 +++++++++++++-- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/__tests__/bucket-sync.spec.ts b/__tests__/bucket-sync.spec.ts index e552da433..1be191332 100644 --- a/__tests__/bucket-sync.spec.ts +++ b/__tests__/bucket-sync.spec.ts @@ -13,6 +13,7 @@ import { s3Head, s3Get, s3Put, + s3Delete, downloadDb, uploadDb, } from "../scripts/sync-db-bucket.mjs"; @@ -172,6 +173,14 @@ describe("s3 operations (mocked fetch)", () => { expect(calls[0].headers.Authorization).toMatch(/^AWS4-HMAC-SHA256 /); }); + it("s3Delete issues DELETE and reports missing keys", async () => { + const calls = mockFetchOnce(204); + expect(await s3Delete(CFG, "db/old.db")).toBe(true); + expect(calls[0].method).toBe("DELETE"); + mockFetchOnce(404); + expect(await s3Delete(CFG, "db/old.db")).toBe(false); + }); + it("downloadDb restores main file; uploadDb skips missing sidecars", async () => { const dir = mkdtempSync(join(tmpdir(), "sync-")); const db = join(dir, "app.db"); diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index c24180fa2..1df6324e6 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -14,7 +14,8 @@ node /app/scripts/sync-db-bucket.mjs download || echo "bucket download skipped npx -y prisma@6.19.0 migrate deploy # Periodically push the DB file back to the bucket (crash safety), -# plus a final push on shutdown. +# plus a final push on shutdown. NOTE: no `exec` below — it would replace +# this shell and kill the background uploader + trap with it. ( while true; do sleep 300 @@ -22,9 +23,11 @@ npx -y prisma@6.19.0 migrate deploy done ) & UPLOADER_PID=$! -trap 'kill $UPLOADER_PID 2>/dev/null; node /app/scripts/sync-db-bucket.mjs upload || true' TERM INT +trap 'kill $APP_PID $UPLOADER_PID 2>/dev/null; node /app/scripts/sync-db-bucket.mjs upload || true; exit 143' TERM INT # Fix /data permissions and run app as nextjs user chown -R nextjs:nodejs /data export HOME=/home/nextjs -exec su -s /bin/sh nextjs -c "HOSTNAME=0.0.0.0 PORT=${PORT:-3737} node server.js" +su -s /bin/sh nextjs -c "HOSTNAME=0.0.0.0 PORT=${PORT:-3737} node server.js" & +APP_PID=$! +wait $APP_PID diff --git a/scripts/sync-db-bucket.mjs b/scripts/sync-db-bucket.mjs index 5cfbbfae3..f11cada6f 100644 --- a/scripts/sync-db-bucket.mjs +++ b/scripts/sync-db-bucket.mjs @@ -150,6 +150,13 @@ export async function s3Put(cfg, key, srcPath) { return true; } +export async function s3Delete(cfg, key) { + const res = await s3Fetch(cfg, "DELETE", key); + if (res.status === 404) return false; + if (!res.ok) throw new Error(`S3 DELETE ${key} -> HTTP ${res.status}`); + return true; +} + export async function downloadDb(cfg, dbPath) { mkdirSync(dirname(dbPath), { recursive: true }); let restored = false; @@ -180,14 +187,18 @@ if (isMain) { console.log("bucket-sync: S3 bucket not configured — skipping."); process.exit(0); } - if (mode !== "download" && mode !== "upload") { - console.error("usage: node scripts/sync-db-bucket.mjs download|upload [dbPath]"); + if (mode !== "download" && mode !== "upload" && mode !== "delete") { + console.error("usage: node scripts/sync-db-bucket.mjs download|upload|delete [dbPath|key]"); process.exit(2); } try { if (mode === "download") { const restored = await downloadDb(cfg, dbPath); console.log(restored ? `bucket-sync: restored ${dbPath} from bucket.` : "bucket-sync: no object in bucket — fresh database."); + } else if (mode === "delete") { + const key = process.argv[3] ?? dbObjectKey(dbPath); + const ok = await s3Delete(cfg, key); + console.log(ok ? `bucket-sync: deleted ${key}.` : `bucket-sync: ${key} not found.`); } else { const ok = await uploadDb(cfg, dbPath); console.log(ok ? `bucket-sync: uploaded ${dbPath} to bucket.` : "bucket-sync: no local db file — nothing uploaded."); From 543b8ead889f44d46e4ec8288b443c2e1a0c53c8 Mon Sep 17 00:00:00 2001 From: Futuregh Date: Thu, 17 Sep 2026 01:28:59 +0000 Subject: [PATCH 3/7] Dual-workspace UI phases 2-5: switcher, outreach log, funding, workspace filter, MCP log_outreach --- __tests__/job.actions.spec.ts | 7 + __tests__/mcp-log-outreach.spec.ts | 50 ++++ __tests__/outreach.actions.spec.ts | 23 +- .../migration.sql | 2 + prisma/schema.prisma | 2 + src/actions/job/mutations.ts | 4 + src/actions/job/queries.ts | 16 ++ src/actions/outreach/outreach.actions.ts | 94 ++++++++ src/actions/workspace/workspace.actions.ts | 17 ++ src/app/api/mcp/route.ts | 26 +++ src/app/dashboard/myjobs/page.tsx | 6 +- src/app/dashboard/outreach/page.tsx | 24 ++ src/app/dashboard/page.tsx | 3 + src/components/Header.tsx | 13 ++ src/components/dashboard/OutreachCards.tsx | 76 ++++++ src/components/myjobs/AddJob.tsx | 25 +- src/components/myjobs/JobDetails.tsx | 12 + src/components/myjobs/JobsContainer.tsx | 3 + .../myjobs/job-details/JobOutreachTab.tsx | 57 +++++ .../myjobs/job-details/JobSummaryCard.tsx | 6 + .../myjobs/jobs-container/useJobsList.ts | 4 + .../osui/workspace-switcher-client.tsx | 34 +++ src/components/outreach/OutreachLog.tsx | 220 ++++++++++++++++++ src/lib/constants.ts | 6 + src/lib/data/salaryRangeData.ts | 5 + src/lib/jobs/createJobRecord.ts | 1 + src/lib/mcp/toolDescriptions.ts | 2 + src/lib/mcp/tools/logOutreach.ts | 67 ++++++ src/lib/workspaces/active.ts | 31 +++ src/models/addJobForm.schema.ts | 1 + src/models/job.model.ts | 2 + 31 files changed, 834 insertions(+), 5 deletions(-) create mode 100644 __tests__/mcp-log-outreach.spec.ts create mode 100644 prisma/migrations/20260917012014_job_funding_status/migration.sql create mode 100644 src/app/dashboard/outreach/page.tsx create mode 100644 src/components/dashboard/OutreachCards.tsx create mode 100644 src/components/myjobs/job-details/JobOutreachTab.tsx create mode 100644 src/components/osui/workspace-switcher-client.tsx create mode 100644 src/components/outreach/OutreachLog.tsx create mode 100644 src/lib/mcp/tools/logOutreach.ts create mode 100644 src/lib/workspaces/active.ts diff --git a/__tests__/job.actions.spec.ts b/__tests__/job.actions.spec.ts index 20703bf27..bf40357bd 100644 --- a/__tests__/job.actions.spec.ts +++ b/__tests__/job.actions.spec.ts @@ -826,6 +826,11 @@ describe("jobActions", () => { }, orderBy: { createdAt: "asc" }, }, + workspace: { select: { id: true, name: true, type: true } }, + outreach: { + include: { contact: { select: { id: true, name: true } } }, + orderBy: { createdAt: "desc" }, + }, }, }); }); @@ -999,6 +1004,7 @@ describe("jobActions", () => { statusId: jobData.status, jobSourceId: jobData.source, salaryRange: jobData.salaryRange, + fundingStatus: null, createdAt: jobData.createdAt, dueDate: jobData.dueDate, appliedDate: jobData.dateApplied, @@ -1030,6 +1036,7 @@ describe("jobActions", () => { statusId: jobData.status, jobSourceId: jobData.source, salaryRange: jobData.salaryRange, + fundingStatus: null, createdAt: jobData.createdAt, dueDate: jobData.dueDate, description: jobData.jobDescription, diff --git a/__tests__/mcp-log-outreach.spec.ts b/__tests__/mcp-log-outreach.spec.ts new file mode 100644 index 000000000..27f60d4bc --- /dev/null +++ b/__tests__/mcp-log-outreach.spec.ts @@ -0,0 +1,50 @@ +import { handleLogOutreach } from "@/lib/mcp/tools/logOutreach"; +import { PrismaClient } from "@prisma/client"; + +vi.mock("@prisma/client", () => { + const mPrismaClient = { + job: { count: vi.fn() }, + contact: { findFirst: vi.fn(), create: vi.fn() }, + outreach: { create: vi.fn() }, + }; + return { PrismaClient: vi.fn(function () { return mPrismaClient; }) }; +}); +vi.mock("@/lib/mcp/rate-limit", () => ({ + checkMcpRateLimit: () => ({ allowed: true, resetIn: 0 }), +})); + +const prisma = new PrismaClient() as unknown as { + job: { count: ReturnType }; + contact: { findFirst: ReturnType; create: ReturnType }; + outreach: { create: ReturnType }; +}; + +describe("mcp log_outreach", () => { + it("logs with auto follow-up and reuses existing contacts", async () => { + prisma.job.count.mockResolvedValue(1); + prisma.contact.findFirst.mockResolvedValue({ id: "c1" }); + prisma.outreach.create.mockImplementation(async ({ data }: any) => ({ + id: "o1", + followUpDue: data.followUpDue, + })); + const res = await handleLogOutreach( + { jobId: "j1", contactName: "Prof. X", subject: "Hi", sentAt: "2026-09-17T00:00:00.000Z" }, + "u1" + ); + expect(res.content[0].text).toMatch(/Outreach logged \(o1\)\. Follow-up due 2026-09-29/); + expect(prisma.contact.create).not.toHaveBeenCalled(); + }); + + it("creates missing contacts and rejects orphan outreach", async () => { + prisma.job.count.mockResolvedValue(1); + prisma.contact.findFirst.mockResolvedValue(null); + prisma.contact.create.mockResolvedValue({ id: "c2" }); + prisma.outreach.create.mockResolvedValue({ id: "o2", followUpDue: new Date() }); + await handleLogOutreach({ contactName: "New Prof" }, "u1"); + expect(prisma.contact.create).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ name: "New Prof" }) }) + ); + const res = await handleLogOutreach({}, "u1"); + expect(res.content[0].text).toMatch(/supply jobId or contactName/); + }); +}); diff --git a/__tests__/outreach.actions.spec.ts b/__tests__/outreach.actions.spec.ts index d9216a0b6..9278dd4cf 100644 --- a/__tests__/outreach.actions.spec.ts +++ b/__tests__/outreach.actions.spec.ts @@ -1,18 +1,19 @@ -import { logOutreach, getFollowUpsDue, markOutreachReplied } from "@/actions/outreach/outreach.actions"; +import { logOutreach, getFollowUpsDue, markOutreachReplied, getUpcomingDeadlines, getOutreachList } from "@/actions/outreach/outreach.actions"; import { getCurrentUser } from "@/utils/user.utils"; import { PrismaClient } from "@prisma/client"; vi.mock("@prisma/client", () => { const mPrismaClient = { - job: { count: vi.fn() }, + job: { count: vi.fn(), findMany: vi.fn() }, outreach: { create: vi.fn(), findMany: vi.fn(), update: vi.fn() }, + contact: { findMany: vi.fn() }, }; return { PrismaClient: vi.fn(function () { return mPrismaClient; }) }; }); vi.mock("@/utils/user.utils", () => ({ getCurrentUser: vi.fn() })); const prisma = new PrismaClient() as unknown as { - job: { count: ReturnType }; + job: { count: ReturnType; findMany: ReturnType }; outreach: { create: ReturnType; findMany: ReturnType; @@ -59,4 +60,20 @@ describe("outreach actions", () => { expect(prisma.outreach.update.mock.calls[0][0].data).toMatchObject({ outcome: "meeting" }); expect(prisma.outreach.update.mock.calls[0][0].data.repliedAt).toBeInstanceOf(Date); }); + + it("getUpcomingDeadlines merges and sorts deadlines + follow-ups", async () => { + prisma.job.findMany.mockResolvedValue([ + { id: "j1", dueDate: new Date("2026-10-15"), JobTitle: { label: "PhD" }, Company: { label: "VU" } }, + ]); + prisma.outreach.findMany.mockResolvedValue([ + { id: "o1", followUpDue: new Date("2026-09-29"), contact: { name: "Prof. X" } }, + ]); + const items = (await getUpcomingDeadlines(10, new Date("2026-09-17"))) as { + kind: string; + refId: string; + }[]; + expect(items).toHaveLength(2); + expect(items[0]).toMatchObject({ kind: "follow-up", refId: "o1" }); + expect(items[1]).toMatchObject({ kind: "deadline", refId: "j1" }); + }); }); diff --git a/prisma/migrations/20260917012014_job_funding_status/migration.sql b/prisma/migrations/20260917012014_job_funding_status/migration.sql new file mode 100644 index 000000000..3500df582 --- /dev/null +++ b/prisma/migrations/20260917012014_job_funding_status/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Job" ADD COLUMN "fundingStatus" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c040c7acc..148c9ab1d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -388,6 +388,8 @@ model Job { JobSource JobSource? @relation(fields: [jobSourceId], references: [id]) jobSourceId String? salaryRange String? + // School workspace: funded | self-funded | scholarship | unknown + fundingStatus String? Location Location? @relation(fields: [locationId], references: [id]) locationId String? Interview Interview[] diff --git a/src/actions/job/mutations.ts b/src/actions/job/mutations.ts index 10ac08eb6..ad0c53522 100644 --- a/src/actions/job/mutations.ts +++ b/src/actions/job/mutations.ts @@ -75,6 +75,7 @@ export const addJob = async ( status, source, salaryRange, + fundingStatus, dueDate, dateApplied, jobDescription, @@ -102,6 +103,7 @@ export const addJob = async ( statusId: status, jobSourceId: source, salaryRange: salaryRange || null, + fundingStatus: fundingStatus || null, dueDate, appliedDate: dateApplied, description: jobDescription, @@ -141,6 +143,7 @@ export const updateJob = async ( status, source, salaryRange, + fundingStatus, dueDate, dateApplied, jobDescription, @@ -196,6 +199,7 @@ export const updateJob = async ( statusId: status, jobSourceId: source, salaryRange: salaryRange || null, + fundingStatus: fundingStatus || null, createdAt: new Date(), dueDate: dueDate, appliedDate: dateApplied, diff --git a/src/actions/job/queries.ts b/src/actions/job/queries.ts index d0ada893d..b71aef5af 100644 --- a/src/actions/job/queries.ts +++ b/src/actions/job/queries.ts @@ -23,6 +23,9 @@ const JOB_LIST_SELECT = { matchScore: true, matchData: true, discoveryStatus: true, + workspaceId: true, + fundingStatus: true, + workspace: { select: { id: true, name: true, type: true } }, _count: { select: { Notes: true } }, }; @@ -71,6 +74,11 @@ const JOB_DETAILS_INCLUDE = { }, orderBy: { createdAt: "asc" as const }, }, + workspace: { select: { id: true, name: true, type: true } }, + outreach: { + include: { contact: { select: { id: true, name: true } } }, + orderBy: { createdAt: "desc" as const }, + }, }; type JobsListFilters = { @@ -81,6 +89,7 @@ type JobsListFilters = { titleValue?: string; locationValue?: string; sourceValue?: string; + workspaceId?: string; }; const buildJobsWhereClause = (userId: string, filters: JobsListFilters) => { @@ -92,6 +101,7 @@ const buildJobsWhereClause = (userId: string, filters: JobsListFilters) => { titleValue, locationValue, sourceValue, + workspaceId, } = filters; const filterBy = filter @@ -129,6 +139,10 @@ const buildJobsWhereClause = (userId: string, filters: JobsListFilters) => { whereClause.Company = { value: companyValue }; } + if (workspaceId) { + whereClause.workspaceId = workspaceId; + } + if (titleValue) { whereClause.JobTitle = { value: titleValue }; } @@ -180,6 +194,7 @@ export const getJobsList = async ( titleValue?: string, locationValue?: string, sourceValue?: string, + workspaceId?: string, ): Promise => { try { const user = await requireUser(); @@ -193,6 +208,7 @@ export const getJobsList = async ( titleValue, locationValue, sourceValue, + workspaceId, }); const [data, total] = await Promise.all([ diff --git a/src/actions/outreach/outreach.actions.ts b/src/actions/outreach/outreach.actions.ts index 08f4ecf8e..5107c48e0 100644 --- a/src/actions/outreach/outreach.actions.ts +++ b/src/actions/outreach/outreach.actions.ts @@ -66,3 +66,97 @@ export const markOutreachReplied = async (outreachId: string, outcome = "positiv return handleError(error, "Failed to update outreach. "); } }; + +export const getOutreachList = async () => { + try { + const user = await requireUser(); + return await prisma.outreach.findMany({ + where: { OR: [{ job: { userId: user.id } }, { contact: { createdBy: user.id } }] }, + include: { + job: { select: { id: true, JobTitle: { select: { label: true } } } }, + contact: { select: { id: true, name: true } }, + }, + orderBy: { createdAt: "desc" }, + take: 200, + }); + } catch (error) { + return handleError(error, "Failed to fetch outreach. "); + } +}; + +export const getJobOptions = async () => { + try { + const user = await requireUser(); + return await prisma.job.findMany({ + where: { userId: user.id }, + select: { id: true, JobTitle: { select: { label: true } }, Company: { select: { label: true } } }, + orderBy: { createdAt: "desc" }, + take: 200, + }); + } catch (error) { + return handleError(error, "Failed to fetch applications. "); + } +}; + +export const getContactOptions = async () => { + try { + const user = await requireUser(); + return await prisma.contact.findMany({ + where: { createdBy: user.id }, + select: { id: true, name: true, relationship: true }, + orderBy: { name: "asc" }, + take: 200, + }); + } catch (error) { + return handleError(error, "Failed to fetch contacts. "); + } +}; + +// Next N application deadlines (job dueDate + outreach follow-ups), merged. +export const getUpcomingDeadlines = async (limit = 10, now = new Date()) => { + try { + const user = await requireUser(); + const [jobs, outreach] = await Promise.all([ + prisma.job.findMany({ + where: { userId: user.id, dueDate: { gte: now } }, + select: { + id: true, + dueDate: true, + JobTitle: { select: { label: true } }, + Company: { select: { label: true } }, + }, + orderBy: { dueDate: "asc" }, + take: limit, + }), + prisma.outreach.findMany({ + where: { repliedAt: null, followUpDue: { gte: now }, job: { userId: user.id } }, + select: { + id: true, + followUpDue: true, + contact: { select: { name: true } }, + }, + orderBy: { followUpDue: "asc" }, + take: limit, + }), + ]); + const items = [ + ...jobs.map((j) => ({ + kind: "deadline" as const, + date: j.dueDate as Date, + label: `${j.JobTitle?.label ?? "Application"} — ${j.Company?.label ?? ""}`, + refId: j.id, + })), + ...outreach.map((o) => ({ + kind: "follow-up" as const, + date: o.followUpDue as Date, + label: `Follow up — ${o.contact?.name ?? "contact"}`, + refId: o.id, + })), + ] + .sort((a, b) => a.date.getTime() - b.date.getTime()) + .slice(0, limit); + return items; + } catch (error) { + return handleError(error, "Failed to fetch deadlines. "); + } +}; diff --git a/src/actions/workspace/workspace.actions.ts b/src/actions/workspace/workspace.actions.ts index 27adc5d51..bf179adb4 100644 --- a/src/actions/workspace/workspace.actions.ts +++ b/src/actions/workspace/workspace.actions.ts @@ -70,3 +70,20 @@ export const deleteWorkspaceById = async (workspaceId: string) => { return handleError(error, "Failed to delete workspace. "); } }; + +export const setJobWorkspace = async (jobId: string, workspaceId: string | null) => { + try { + const user = await requireUser(); + if (workspaceId) { + const ws = await prisma.workspace.findFirst({ where: { id: workspaceId, userId: user.id } }); + if (!ws) throw new Error("Workspace not found."); + } + const job = await prisma.job.update({ + where: { id: jobId, userId: user.id }, + data: { workspaceId }, + }); + return { data: job, success: true }; + } catch (error) { + return handleError(error, "Failed to move application. "); + } +}; diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index 9050e3064..2e8af5815 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -31,6 +31,7 @@ import { handleFindJob } from "@/lib/mcp/tools/findJob"; import { handleUpdateJob } from "@/lib/mcp/tools/updateJob"; import { handleAddJobsBatch } from "@/lib/mcp/tools/addJobsBatch"; import { handleSaveMatchResultsBatch } from "@/lib/mcp/tools/saveMatchResultsBatch"; +import { handleLogOutreach, McpLogOutreachInputShape, McpLogOutreachSchema } from "@/lib/mcp/tools/logOutreach"; function isMcpEnabled(): boolean { const env = process.env.MCP_ENABLED; @@ -265,6 +266,31 @@ async function handler(req: Request): Promise { }, ); + server.tool( + "log_outreach", + MCP_TOOL_DESCRIPTIONS.log_outreach, + McpLogOutreachInputShape, + async (rawInput) => { + if (!auth.scopes.includes("jobs:write")) { + return { + content: [ + { type: "text" as const, text: "Insufficient scope. Required: jobs:write" }, + ], + }; + } + const parsed = McpLogOutreachSchema.safeParse(rawInput); + if (!parsed.success) { + const issues = parsed.error.issues.map((i) => i.message).join("; "); + return { + content: [ + { type: "text" as const, text: `Validation error: ${issues}` }, + ], + }; + } + return handleLogOutreach(parsed.data, userId); + }, + ); + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, // stateless mode enableJsonResponse: true, // return plain JSON instead of SSE stream diff --git a/src/app/dashboard/myjobs/page.tsx b/src/app/dashboard/myjobs/page.tsx index 1bbbfc26b..637b41879 100644 --- a/src/app/dashboard/myjobs/page.tsx +++ b/src/app/dashboard/myjobs/page.tsx @@ -11,8 +11,10 @@ export const metadata: Metadata = { title: "My Jobs | JobSync", }; +import { getActiveWorkspace } from "@/lib/workspaces/active"; + async function MyJobs() { - const [statuses, companies, titles, locations, sources, tags] = + const [statuses, companies, titles, locations, sources, tags, { active }] = await Promise.all([ getStatusList(), getAllCompanies(), @@ -20,6 +22,7 @@ async function MyJobs() { getAllJobLocations(), getJobSourceList(), getAllTags(), + getActiveWorkspace().catch(() => ({ workspaces: [], active: null })), ]); return (
@@ -30,6 +33,7 @@ async function MyJobs() { sources={sources} statuses={statuses} tags={tags ?? []} + activeWorkspaceId={active?.id ?? null} />
); diff --git a/src/app/dashboard/outreach/page.tsx b/src/app/dashboard/outreach/page.tsx new file mode 100644 index 000000000..7c0983ff8 --- /dev/null +++ b/src/app/dashboard/outreach/page.tsx @@ -0,0 +1,24 @@ +import { Metadata } from "next"; +import { getOutreachList, getJobOptions, getContactOptions } from "@/actions/outreach/outreach.actions"; +import OutreachLog from "@/components/outreach/OutreachLog"; + +export const metadata: Metadata = { + title: "Outreach | JobSync", +}; + +export default async function OutreachPage() { + const [rows, jobs, contacts] = await Promise.all([ + getOutreachList().catch(() => []), + getJobOptions().catch(() => []), + getContactOptions().catch(() => []), + ]); + return ( +
+ +
+ ); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 06972245d..fc8c5a49b 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -11,6 +11,7 @@ import JobsActivityCard from "@/components/dashboard/JobsActivityCard"; import JobsApplied from "@/components/dashboard/JobsAppliedCard"; import RecentCardToggle from "@/components/dashboard/RecentCardToggle"; import WeeklyBarChartToggle from "@/components/dashboard/WeeklyBarChartToggle"; +import { FollowUpsCard, DeadlinesCard } from "@/components/dashboard/OutreachCards"; import { Metadata } from "next"; @@ -48,6 +49,8 @@ export default async function Dashboard() { { label: "30d", summary: summary30Days }, ]} /> + + null); + const { workspaces, active } = await getActiveWorkspace().catch(() => ({ + workspaces: [], + active: null, + })); return (
@@ -59,6 +68,10 @@ async function Header() {

JobSync - Job Search Assistant

+ ({ id: w.id, name: w.name, type: w.type }))} + activeId={active?.id ?? null} + />
{/* [])) as unknown as { + id: string; + followUpDue: Date | null; + contact?: { name: string } | null; + }[]; + return ( + + + Follow-ups due + + Outreach → + + + + {rows.length === 0 ? ( +

Nothing overdue. 🎉

+ ) : ( +
    + {rows.slice(0, 5).map((r) => ( +
  • + {r.contact?.name ?? "Contact"} + + {r.followUpDue ? fmt(r.followUpDue) : "due"} + +
  • + ))} +
+ )} +
+
+ ); +} + +export async function DeadlinesCard() { + const items = (await getUpcomingDeadlines(8).catch(() => [])) as unknown as { + kind: string; + date: Date; + label: string; + refId: string; + }[]; + return ( + + + Upcoming deadlines + + + {items.length === 0 ? ( +

No deadlines on the radar.

+ ) : ( +
    + {items.map((d) => ( +
  • + + + {d.kind === "follow-up" ? "follow-up" : "deadline"} + + {d.label} + + {fmt(d.date)} +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/myjobs/AddJob.tsx b/src/components/myjobs/AddJob.tsx index a6f5704b1..cc7fbd92d 100644 --- a/src/components/myjobs/AddJob.tsx +++ b/src/components/myjobs/AddJob.tsx @@ -40,7 +40,7 @@ import { import { RadioGroup, RadioGroupItem } from "../ui/radio-group"; import SelectFormCtrl from "../Select"; import { DatePicker } from "../DatePicker"; -import { SALARY_RANGES } from "@/lib/data/salaryRangeData"; +import { SALARY_RANGES, FUNDING_STATUSES } from "@/lib/data/salaryRangeData"; import TiptapEditor from "../TiptapEditor"; import { Input } from "../ui/input"; import { Switch } from "../ui/switch"; @@ -121,6 +121,7 @@ export function AddJob({ dueDate: addDays(new Date(), 3), status: jobStatuses[0]?.id, salaryRange: "", + fundingStatus: "", jobUrl: "", jobDescription: "N/A", location: locations.find((l) => l.id === lastLocationId)?.id, @@ -172,6 +173,7 @@ export function AddJob({ status: editJob.Status.id, dueDate: editJob.dueDate, salaryRange: editJob.salaryRange ?? "", + fundingStatus: (editJob as { fundingStatus?: string | null }).fundingStatus ?? "", jobDescription: editJob.description, applied: editJob.applied, jobUrl: editJob.jobUrl ?? "", @@ -560,6 +562,27 @@ export function AddJob({ />
+ {/* Funding Status (School workspace) */} +
+ ( + + Funding Status + + + + )} + /> +
+ {/* Resume */}
)} + + Outreach + {((job as unknown as { outreach?: unknown[] }).outreach?.length ?? 0) > 0 && ( + + {(job as unknown as { outreach: unknown[] }).outreach.length} + + )} + @@ -275,6 +284,9 @@ function JobDetails({ /> + + +
{ diff --git a/src/components/myjobs/job-details/JobOutreachTab.tsx b/src/components/myjobs/job-details/JobOutreachTab.tsx new file mode 100644 index 000000000..ad48068fa --- /dev/null +++ b/src/components/myjobs/job-details/JobOutreachTab.tsx @@ -0,0 +1,57 @@ +import Link from "next/link"; +import { Card } from "../../ui/card"; +import { JobTabEmptyState } from "./JobTabEmptyState"; +import { Mails } from "lucide-react"; + +export type JobOutreachRow = { + id: string; + subject: string | null; + paperCited: string | null; + sentAt: Date | string | null; + followUpDue: Date | string | null; + repliedAt: Date | string | null; + outcome: string | null; + contact?: { name: string } | null; +}; + +function fmt(d: Date | string | null) { + if (!d) return "—"; + return new Date(d).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }); +} + +export function JobOutreachTab({ outreach }: { outreach: JobOutreachRow[] }) { + if (!outreach || outreach.length === 0) { + return ( + + (window.location.href = "/dashboard/outreach")} + /> + + ); + } + return ( + +
    + {outreach.map((o) => ( +
  • +
    + {o.contact?.name ?? o.subject ?? "Outreach"} + + {o.repliedAt ? `replied (${o.outcome ?? "—"})` : `follow up ${fmt(o.followUpDue)}`} + +
    + {o.paperCited &&

    Paper: {o.paperCited}

    } +

    Sent {fmt(o.sentAt)}

    +
  • + ))} +
+ + Open outreach log → + +
+ ); +} diff --git a/src/components/myjobs/job-details/JobSummaryCard.tsx b/src/components/myjobs/job-details/JobSummaryCard.tsx index c082223c7..50d1d6c01 100644 --- a/src/components/myjobs/job-details/JobSummaryCard.tsx +++ b/src/components/myjobs/job-details/JobSummaryCard.tsx @@ -63,6 +63,12 @@ export function JobSummaryCard({

Salary Range

{job.salaryRange || "-"}

+
+

Funding

+

+ {(job as { fundingStatus?: string | null }).fundingStatus || "-"} +

+

Source

{job.JobSource?.label ?? "-"}

diff --git a/src/components/myjobs/jobs-container/useJobsList.ts b/src/components/myjobs/jobs-container/useJobsList.ts index 34a3c4b1a..ef4214afd 100644 --- a/src/components/myjobs/jobs-container/useJobsList.ts +++ b/src/components/myjobs/jobs-container/useJobsList.ts @@ -18,12 +18,14 @@ export function useJobsList({ titleFilter, locationFilter, sourceFilter, + workspaceId, }: { companyFilter: string | null; appliedFilter: boolean; titleFilter: string | null; locationFilter: string | null; sourceFilter: string | null; + workspaceId?: string | null; }) { const { jobWrites } = useAgentChat(); const [jobs, setJobs] = useState([]); @@ -68,6 +70,7 @@ export function useJobsList({ titleFilter || undefined, locationFilter || undefined, sourceFilter || undefined, + workspaceId || undefined, ); if (success && data) { setJobs((prev) => (page === 1 ? data : [...prev, ...data])); @@ -86,6 +89,7 @@ export function useJobsList({ titleFilter, locationFilter, sourceFilter, + workspaceId, ], ); diff --git a/src/components/osui/workspace-switcher-client.tsx b/src/components/osui/workspace-switcher-client.tsx new file mode 100644 index 000000000..26515aa8e --- /dev/null +++ b/src/components/osui/workspace-switcher-client.tsx @@ -0,0 +1,34 @@ +"use client"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { WorkspaceSwitcher, type OsuiWorkspace } from "./workspace-switcher"; +import { setActiveWorkspaceId } from "@/lib/workspaces/active"; + +export function WorkspaceSwitcherClient({ + workspaces, + activeId, +}: { + workspaces: OsuiWorkspace[]; + activeId: string | null; +}) { + const router = useRouter(); + const [busy, setBusy] = useState(false); + if (workspaces.length === 0) return null; + return ( +
+ { + setBusy(true); + try { + await setActiveWorkspaceId(id); + router.refresh(); + } finally { + setBusy(false); + } + }} + /> +
+ ); +} diff --git a/src/components/outreach/OutreachLog.tsx b/src/components/outreach/OutreachLog.tsx new file mode 100644 index 000000000..5b1d3881c --- /dev/null +++ b/src/components/outreach/OutreachLog.tsx @@ -0,0 +1,220 @@ +"use client"; +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Textarea } from "@/components/ui/textarea"; +import { toastError, toastSuccess } from "@/lib/toast"; +import { + logOutreach, + markOutreachReplied, + getFollowUpsDue, +} from "@/actions/outreach/outreach.actions"; + +type Row = { + id: string; + subject: string | null; + paperCited: string | null; + sentAt: string | null; + followUpDue: string | null; + repliedAt: string | null; + outcome: string | null; + job: { JobTitle?: { label: string } | null } | null; + contact: { name: string } | null; +}; + +type JobOpt = { id: string; JobTitle: { label: string }; Company: { label: string } | null }; +type ContactOpt = { id: string; name: string; relationship: string | null }; + +function fmt(d: string | null) { + if (!d) return "—"; + return new Date(d).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }); +} + +export default function OutreachLog({ rows, jobs, contacts }: { rows: Row[]; jobs: JobOpt[]; contacts: ContactOpt[] }) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [showDueOnly, setShowDueOnly] = useState(false); + const [dueIds, setDueIds] = useState | null>(null); + const [form, setForm] = useState({ jobId: "", contactId: "", subject: "", paperCited: "", body: "" }); + + const visible = showDueOnly && dueIds ? rows.filter((r) => dueIds.has(r.id)) : rows; + + async function toggleDueOnly() { + if (showDueOnly) { + setShowDueOnly(false); + return; + } + const due = ((await getFollowUpsDue().catch(() => [])) ?? []) as { id: string }[]; + setDueIds(new Set(due.map((d) => d.id))); + setShowDueOnly(true); + } + + async function submit() { + setSaving(true); + try { + const res = (await logOutreach({ + jobId: form.jobId || undefined, + contactId: form.contactId || undefined, + subject: form.subject || undefined, + paperCited: form.paperCited || undefined, + body: form.body || undefined, + })) as { success: boolean; message?: string }; + if (!res.success) throw new Error(res.message ?? "Failed to log outreach."); + toastSuccess("Outreach logged — follow-up set ~12 days out."); + setOpen(false); + setForm({ jobId: "", contactId: "", subject: "", paperCited: "", body: "" }); + router.refresh(); + } catch (e) { + toastError(e instanceof Error ? e.message : "Failed to log outreach."); + } finally { + setSaving(false); + } + } + + async function replied(id: string) { + const res = (await markOutreachReplied(id, "positive")) as { success: boolean; message?: string }; + if (!res.success) toastError(res.message ?? "Update failed."); + else { + toastSuccess("Marked as replied."); + router.refresh(); + } + } + + return ( + + + Outreach log +
+ + + + + + + + Log cold outreach + +
+
+ + +
+
+ + +
+
+ + setForm({ ...form, subject: e.target.value })} placeholder="Prospective PhD — surgical micro-robotics" /> +
+
+ + setForm({ ...form, paperCited: e.target.value })} placeholder="Which paper you referenced" /> +
+
+ +