Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 161 additions & 8 deletions apps/migrate/src/seed.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,167 @@
// Seed script: creates the demo user and starter data.
// Safe to re-run once the data model exists.
// Run via: pnpm db:seed
// Seeds the demo user and a believable job search: statuses across the
// pipeline, events spread over weeks, follow-ups due soon and overdue.
// Idempotent by construction — run twice, same database.
// Standard recovery pair: `pnpm db:reset`, then `pnpm db:seed`.
// Run via: pnpm db:seed (see docs/specs/jobs/seed.md)

import { prisma } from "@project/db";
import type { JobEventType, JobStatus, JobSource } from "@project/db";

const DAY = 24 * 60 * 60 * 1000;
const daysAgo = (n: number) => new Date(Date.now() - n * DAY);
const daysFromNow = (n: number) => new Date(Date.now() + n * DAY);

type SeedEvent = {
type: JobEventType;
daysAgo: number;
from?: JobStatus;
to?: JobStatus;
note?: string;
};

type SeedJob = {
company: string;
title: string;
status: JobStatus;
appliedDaysAgo: number;
followUpInDays?: number; // negative = overdue
source?: JobSource;
location?: string;
salary?: [number, number];
notes?: string;
history: SeedEvent[]; // beyond CREATED, oldest first
};

// A six-week search that reads like a real one. Every job gets a CREATED
// event at its application date; `history` layers what happened after.
const SEARCH: SeedJob[] = [
{
company: "Datadog", title: "Software Engineer, Early Career", status: "INTERVIEWING",
appliedDaysAgo: 38, followUpInDays: 2, source: "COMPANY_WEBSITE", location: "New York, NY",
salary: [125000, 150000], notes: "Referred by Priya after the career fair.",
history: [
{ type: "STATUS_CHANGE", daysAgo: 30, from: "APPLIED", to: "INTERVIEWING" },
{ type: "NOTE_ADDED", daysAgo: 12, note: "Phone screen went well — systems round next." },
],
},
{
company: "Mongo Consulting", title: "Junior Full-Stack Developer", status: "OFFER",
appliedDaysAgo: 41, followUpInDays: 1, source: "REFERRAL", location: "Remote (US)",
salary: [95000, 105000],
history: [
{ type: "STATUS_CHANGE", daysAgo: 33, from: "APPLIED", to: "INTERVIEWING" },
{ type: "STATUS_CHANGE", daysAgo: 4, from: "INTERVIEWING", to: "OFFER" },
{ type: "NOTE_ADDED", daysAgo: 3, note: "Offer expires Friday — negotiate?" },
],
},
{
company: "Spotify", title: "Associate Engineer, Platform", status: "REJECTED",
appliedDaysAgo: 35, source: "LINKEDIN", location: "New York, NY",
history: [
{ type: "STATUS_CHANGE", daysAgo: 28, from: "APPLIED", to: "INTERVIEWING" },
{ type: "STATUS_CHANGE", daysAgo: 14, from: "INTERVIEWING", to: "REJECTED" },
{ type: "NOTE_ADDED", daysAgo: 14, note: "Asked for feedback; recruiter said try again next cycle." },
],
},
{
company: "MTA IT Bureau", title: "Web Developer I", status: "APPLIED",
appliedDaysAgo: 25, followUpInDays: -3, source: "JOB_BOARD", location: "Brooklyn, NY",
notes: "Civil service posting — long timeline expected.",
history: [],
},
{
company: "Ramp", title: "Software Engineer — New Grad", status: "INTERVIEWING",
appliedDaysAgo: 21, followUpInDays: 5, source: "RECRUITER", location: "New York, NY",
salary: [130000, 160000],
history: [
{ type: "STATUS_CHANGE", daysAgo: 9, from: "APPLIED", to: "INTERVIEWING" },
],
},
{
company: "Vimeo", title: "Frontend Engineer, Growth", status: "WITHDRAWN",
appliedDaysAgo: 19, source: "LINKEDIN",
history: [
{ type: "NOTE_ADDED", daysAgo: 11, note: "Role reposted at lower band." },
{ type: "STATUS_CHANGE", daysAgo: 10, from: "APPLIED", to: "WITHDRAWN" },
],
},
{
company: "NYC Health + Hospitals", title: "Junior Application Developer", status: "APPLIED",
appliedDaysAgo: 12, followUpInDays: 6, source: "JOB_BOARD", location: "Manhattan, NY",
history: [],
},
{
company: "Etsy", title: "Software Engineer I", status: "APPLIED",
appliedDaysAgo: 8, followUpInDays: -1, source: "COMPANY_WEBSITE", location: "Brooklyn, NY",
salary: [115000, 135000],
history: [{ type: "NOTE_ADDED", daysAgo: 6, note: "Take-home received — due next week." }],
},
{
company: "Grow Therapy", title: "Associate Software Engineer", status: "APPLIED",
appliedDaysAgo: 5, source: "LINKEDIN", location: "Remote (US)",
history: [],
},
{
company: "Bloomberg", title: "Software Engineer 2026 Graduate", status: "APPLIED",
appliedDaysAgo: 2, followUpInDays: 12, source: "COMPANY_WEBSITE", location: "New York, NY",
salary: [140000, 165000],
history: [],
},
];

async function main() {
console.log("seed: no data model yet — nothing to do");
const user = await prisma.user.upsert({
where: { id: "demo-user" },
update: {},
create: { id: "demo-user", name: "Demo User", email: "demo@example.edu" },
});

const existing = await prisma.job.count({ where: { userId: user.id } });
if (existing > 0) {
console.log(`seed: ${existing} jobs already present, leaving them alone`);
return;
}

for (const s of SEARCH) {
const applied = daysAgo(s.appliedDaysAgo);
const job = await prisma.job.create({
data: {
userId: user.id,
company: s.company,
title: s.title,
status: s.status,
dateApplied: applied,
followUpDate: s.followUpInDays != null ? daysFromNow(s.followUpInDays) : null,
source: s.source,
location: s.location,
salaryMin: s.salary?.[0],
salaryMax: s.salary?.[1],
notes: s.notes ?? "",
createdAt: applied,
},
});
await prisma.jobEvent.create({
data: { jobId: job.id, type: "CREATED", toStatus: "APPLIED", createdAt: applied },
});
for (const e of s.history) {
await prisma.jobEvent.create({
data: {
jobId: job.id,
type: e.type,
fromStatus: e.from,
toStatus: e.to,
note: e.note,
createdAt: daysAgo(e.daysAgo),
},
});
}
}
console.log(`seed: created ${SEARCH.length} jobs for ${user.id}`);
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => prisma.$disconnect());
72 changes: 72 additions & 0 deletions docs/specs/jobs/data-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
---
type: feature
---
# Jobs are rows a user owns; their history is rows that append

## Why
Everything else stands on two tables and three promises. A job seeker's
pipeline is a list of applications and the story of what happened to each —
so the model is a `Job` owned by a `User`, and an append-only `JobEvent`
history per job. Stated once, here, so every other spec can lean on it.

## Where it lives
- `packages/db/prisma/schema.prisma` — the models, enums, and indexes
- `packages/db/prisma/migrations/0001_init.sql` — the schema as applied DDL
- `packages/domain/src/schemas/job.ts` — boundary validation (`CreateJob`)
- `packages/domain/src/queries/jobs.ts` — `listJobs`, `getJob`, `createJob`

## Behavior
- A `Job` belongs to exactly one `User` (`userId`, cascade on delete) and
carries: company, title, a status in the pipeline enum (`APPLIED →
INTERVIEWING → OFFER | REJECTED | WITHDRAWN`), `dateApplied`,
optional `followUpDate`, `source`, `url`, `location`, salary range, and
free-text `notes`.
- A `JobEvent` records one thing that happened to one job: its `type`
(`CREATED`, `STATUS_CHANGE`, `NOTE_ADDED`, `RESTORED`), optional
from/to statuses, an optional note, and when. Events are append-only:
nothing updates or deletes an event row.
- **Promise 1 — scoping.** Every read and write is scoped by the current
user's id. A job that exists but belongs to someone else behaves exactly
like a job that does not exist.
- **Promise 2 — soft delete.** Deletion sets `deletedAt`; reads exclude
rows where `deletedAt` is set. No code path hard-deletes user data.
- **Promise 3 — history rides the change.** A write that implies history
(creating a job, changing its status) writes its `JobEvent` in the same
transaction. `createJob` creates the job and its `CREATED` event
atomically; a crash between the two leaves neither.
- Invalid input never reaches the database: `CreateJob` validates at the
boundary (required non-empty company/title, enum status, URL shape,
`salaryMin ≤ salaryMax`, length caps).

## Examples

| State / input | Behavior |
|---|---|
| `createJob(user, {company, title})` | Job row with status `APPLIED` + one `CREATED` event, atomically |
| `listJobs(userA)` when userB has jobs | userB's jobs never appear |
| Job with `deletedAt` set | Absent from `listJobs` and `getJob` |
| `createJob` input with empty company | Rejected by `CreateJob` before any query runs |
| `salaryMin: 90_000, salaryMax: 80_000` | Rejected: `salaryMin cannot exceed salaryMax` |

## Verify
- `pnpm test` — `tests/integration/jobs.test.ts` exercises all three
promises against in-memory PGlite.
- `pnpm dev`, then `npx prisma studio` in `packages/db` — browse `Job` and
`JobEvent`; the model is the demo.

## Constraints & decisions
- **No auth tables yet.** Identity is the dev stub (`x-user-id` /
`DEV_USER_ID`) until real auth arrives; the schema stays silent about
sessions on purpose (see `docs/specs/auth.md`).
- **`notes` is unbounded text** (capped at the boundary, not the column) —
cheap now, revisited only if it ever hurts.
- **No `[userId, followUpDate]` index.** No query reads by follow-up date
yet; indexes arrive with the queries that earn them.
- **Statuses are an enum, not a table.** The pipeline is product-defined
and small; configurable pipelines are a different product.

## Out of scope
- Reading the history back (`docs/specs/jobs/latest-activity.md` and
`docs/specs/jobs/history.md` own the read models).
- Seed data (`docs/specs/jobs/seed.md`).
- Attachments, tags, contacts — no spec owns these yet.
53 changes: 53 additions & 0 deletions docs/specs/jobs/seed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
type: feature
---
# The seed writes a believable job search, and re-running it changes nothing

## Why
A data model demos as well as its data. The seed gives the demo user a
six-week search worth looking at — statuses across the pipeline, events
spread over weeks, follow-ups due soon and overdue — so every later
feature lands on data that reads like a real search, not lorem ipsum.

## Where it lives
- `apps/migrate/src/seed.ts` — the script (`pnpm db:seed`, dev running)

## Behavior
- Creates (or finds) the demo user, then a fixed set of ten jobs with
realistic companies, titles, sources, locations, and salary ranges.
- Every job gets a `CREATED` event dated to its application date; jobs
with more story get `STATUS_CHANGE` and `NOTE_ADDED` events at
plausible intervals after it.
- Timestamps are relative to the day the seed runs: applications spread
over the prior six weeks; `followUpDate`s straddle the 7-day window —
at least one overdue, at least one due within a week.
- Statuses cover the whole pipeline: multiple `APPLIED`, some
`INTERVIEWING`, and at least one each of `OFFER`, `REJECTED`,
`WITHDRAWN`.
- **Idempotent by construction:** if the demo user already has jobs, the
seed reports and exits without writing. Run twice, same database.

## Examples

| State / input | Behavior |
|---|---|
| Fresh database → `pnpm db:seed` | 10 jobs + their event chains created |
| Seeded database → `pnpm db:seed` | "already present, leaving them alone" — zero writes |
| `pnpm db:reset`, restart dev, seed | Same shape of data, dated relative to today |

## Verify
- Seed twice; `SELECT count(*) FROM "Job"` is identical after each run.
- Browse in Prisma Studio: statuses varied, events ordered sensibly,
follow-ups on both sides of today.

## Constraints & decisions
- **Tests never use the seed** — tests own their data (fixtures in the
test file). The seed is for humans looking at the app.
- **Volume is fixed, not configurable.** Ten jobs is enough to make every
list and rollup interesting; knobs would be speculation.
- **Idempotency is skip-if-present, not upsert-per-row.** Simpler to
reason about, and preserves any edits you made to seeded rows.

## Out of scope
- The data model itself (`docs/specs/jobs/data-model.md`).
- Per-test fixtures (each test file owns its own).
64 changes: 64 additions & 0 deletions packages/db/prisma/migrations/0001_init.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
-- 0001_init — the complete schema, day one.
-- Matches prisma/schema.prisma exactly (Prisma DDL conventions).
-- Regenerate after schema changes with:
-- npx prisma migrate diff --from-empty --to-schema-datamodel prisma/schema.prisma --script

-- Enums
CREATE TYPE "JobStatus" AS ENUM ('APPLIED', 'INTERVIEWING', 'OFFER', 'REJECTED', 'WITHDRAWN');
CREATE TYPE "JobSource" AS ENUM ('LINKEDIN', 'COMPANY_WEBSITE', 'REFERRAL', 'RECRUITER', 'JOB_BOARD', 'OTHER');
CREATE TYPE "JobEventType" AS ENUM ('CREATED', 'STATUS_CHANGE', 'NOTE_ADDED', 'RESTORED');

-- Tables
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"name" TEXT,
"email" TEXT,

CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);

CREATE TABLE "Job" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"company" TEXT NOT NULL,
"title" TEXT NOT NULL,
"status" "JobStatus" NOT NULL DEFAULT 'APPLIED',
"dateApplied" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"followUpDate" TIMESTAMP(3),
"source" "JobSource",
"url" TEXT,
"location" TEXT,
"salaryMin" INTEGER,
"salaryMax" INTEGER,
"notes" TEXT NOT NULL DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),

CONSTRAINT "Job_pkey" PRIMARY KEY ("id")
);

CREATE TABLE "JobEvent" (
"id" TEXT NOT NULL,
"jobId" TEXT NOT NULL,
"type" "JobEventType" NOT NULL,
"fromStatus" "JobStatus",
"toStatus" "JobStatus",
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "JobEvent_pkey" PRIMARY KEY ("id")
);

-- Indexes
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
CREATE INDEX "Job_userId_deletedAt_idx" ON "Job"("userId", "deletedAt");
CREATE INDEX "Job_userId_dateApplied_idx" ON "Job"("userId", "dateApplied");
CREATE INDEX "Job_userId_status_idx" ON "Job"("userId", "status");
CREATE INDEX "JobEvent_jobId_createdAt_idx" ON "JobEvent"("jobId", "createdAt");

-- Foreign keys
ALTER TABLE "Job" ADD CONSTRAINT "Job_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "JobEvent" ADD CONSTRAINT "JobEvent_jobId_fkey"
FOREIGN KEY ("jobId") REFERENCES "Job"("id") ON DELETE CASCADE ON UPDATE CASCADE;
Loading
Loading