diff --git a/apps/migrate/src/seed.ts b/apps/migrate/src/seed.ts index f4f9f20..db6820c 100644 --- a/apps/migrate/src/seed.ts +++ b/apps/migrate/src/seed.ts @@ -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); -}); \ No newline at end of file +main() + .catch((err) => { + console.error(err); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/docs/specs/jobs/data-model.md b/docs/specs/jobs/data-model.md new file mode 100644 index 0000000..6bf33bf --- /dev/null +++ b/docs/specs/jobs/data-model.md @@ -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. diff --git a/docs/specs/jobs/seed.md b/docs/specs/jobs/seed.md new file mode 100644 index 0000000..3f43901 --- /dev/null +++ b/docs/specs/jobs/seed.md @@ -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). diff --git a/packages/db/prisma/migrations/0001_init.sql b/packages/db/prisma/migrations/0001_init.sql new file mode 100644 index 0000000..3f92740 --- /dev/null +++ b/packages/db/prisma/migrations/0001_init.sql @@ -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; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 870d123..cd4e429 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -1,5 +1,12 @@ -// The data model for the entire app. Boilerplate — empty schema with no models. -// Example branches add their own models, enums, and migrations here. +// The data model for the entire app. A User (what the dev identity stub +// scopes by), their Jobs, and an append-only JobEvent history. Minimal but +// complete: every later feature deepens a table that already exists. +// +// Three promises every query keeps (see docs/specs/jobs/data-model.md): +// 1. Every read and write is scoped by userId. +// 2. Rows soft-delete via deletedAt; reads exclude deleted rows. +// 3. A change that implies history writes its JobEvent in the same +// transaction as the change itself. generator client { provider = "prisma-client-js" @@ -11,4 +18,73 @@ generator client { datasource db { provider = "postgresql" url = env("DATABASE_URL") -} \ No newline at end of file +} + +model User { + id String @id @default(cuid()) + name String? + email String? @unique + jobs Job[] +} + +enum JobStatus { + APPLIED + INTERVIEWING + OFFER + REJECTED + WITHDRAWN +} + +enum JobSource { + LINKEDIN + COMPANY_WEBSITE + REFERRAL + RECRUITER + JOB_BOARD + OTHER +} + +model Job { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + company String + title String + status JobStatus @default(APPLIED) + dateApplied DateTime @default(now()) + followUpDate DateTime? + source JobSource? + url String? + location String? + salaryMin Int? + salaryMax Int? + notes String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + events JobEvent[] + + @@index([userId, deletedAt]) + @@index([userId, dateApplied]) + @@index([userId, status]) +} + +enum JobEventType { + CREATED + STATUS_CHANGE + NOTE_ADDED + RESTORED +} + +model JobEvent { + id String @id @default(cuid()) + jobId String + job Job @relation(fields: [jobId], references: [id], onDelete: Cascade) + type JobEventType + fromStatus JobStatus? + toStatus JobStatus? + note String? + createdAt DateTime @default(now()) + + @@index([jobId, createdAt]) +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index ce0e27a..7cf81ec 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -2,4 +2,12 @@ // Apps and other packages import from @project/db, never from @prisma/client directly. // The migration runner (applyMigrations) is available at @project/db/migrate. export { prisma, LOCAL_DEV_URL } from "./client"; -export { PrismaClient } from "./generated/prisma"; \ No newline at end of file +export { PrismaClient } from "./generated/prisma"; +export type { + Job, + JobEvent, + User, + JobStatus, + JobSource, + JobEventType, +} from "./generated/prisma"; diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index ebc0876..c43e1ef 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -1,4 +1,9 @@ // Web-only domain logic: input validation schemas and database queries. -// Boilerplate — empty barrel. Example apps fill this with their own -// schemas and query functions, following the web-only convention. -export {}; \ No newline at end of file +// The worker does not import from this package. +export { + CreateJob, + type CreateJobInput, + JOB_STATUSES, + JOB_SOURCES, +} from "./schemas/job"; +export { listJobs, getJob, createJob } from "./queries/jobs"; diff --git a/packages/domain/src/queries/jobs.ts b/packages/domain/src/queries/jobs.ts new file mode 100644 index 0000000..6fb9399 --- /dev/null +++ b/packages/domain/src/queries/jobs.ts @@ -0,0 +1,43 @@ +// Database queries for jobs. Every query is scoped by userId — no exceptions. +// A change that implies history writes its JobEvent in the same transaction +// (see docs/specs/jobs/data-model.md for the three promises). +import { prisma } from "@project/db"; +import type { CreateJobInput } from "../schemas/job"; + +export function listJobs(userId: string) { + return prisma.job.findMany({ + where: { userId, deletedAt: null }, + orderBy: { dateApplied: "desc" }, + }); +} + +export function getJob(id: string, userId: string) { + return prisma.job.findFirst({ + where: { id, userId, deletedAt: null }, + }); +} + +export async function createJob(userId: string, input: CreateJobInput) { + return prisma.$transaction(async (tx) => { + const job = await tx.job.create({ + data: { + userId, + company: input.company, + title: input.title, + status: input.status, + dateApplied: input.dateApplied, + followUpDate: input.followUpDate, + source: input.source, + url: input.url, + location: input.location, + salaryMin: input.salaryMin, + salaryMax: input.salaryMax, + notes: input.notes, + }, + }); + await tx.jobEvent.create({ + data: { jobId: job.id, type: "CREATED", toStatus: job.status }, + }); + return job; + }); +} diff --git a/packages/domain/src/schemas/job.ts b/packages/domain/src/schemas/job.ts new file mode 100644 index 0000000..f762722 --- /dev/null +++ b/packages/domain/src/schemas/job.ts @@ -0,0 +1,41 @@ +// Validation schema for job create inputs. Runs at the boundary before any +// database operation — invalid shapes never reach the database. +import { z } from "zod"; + +export const JOB_STATUSES = [ + "APPLIED", + "INTERVIEWING", + "OFFER", + "REJECTED", + "WITHDRAWN", +] as const; + +export const JOB_SOURCES = [ + "LINKEDIN", + "COMPANY_WEBSITE", + "REFERRAL", + "RECRUITER", + "JOB_BOARD", + "OTHER", +] as const; + +export const CreateJob = z + .object({ + company: z.string().trim().min(1, "Company is required").max(200), + title: z.string().trim().min(1, "Title is required").max(200), + status: z.enum(JOB_STATUSES).default("APPLIED"), + dateApplied: z.coerce.date().optional(), + followUpDate: z.coerce.date().nullish(), + source: z.enum(JOB_SOURCES).nullish(), + url: z.string().url().max(2000).nullish(), + location: z.string().trim().max(200).nullish(), + salaryMin: z.number().int().nonnegative().nullish(), + salaryMax: z.number().int().nonnegative().nullish(), + notes: z.string().max(5000).optional().default(""), + }) + .refine( + (j) => j.salaryMin == null || j.salaryMax == null || j.salaryMin <= j.salaryMax, + { message: "salaryMin cannot exceed salaryMax", path: ["salaryMin"] } + ); + +export type CreateJobInput = z.infer; diff --git a/tests/integration/jobs.test.ts b/tests/integration/jobs.test.ts new file mode 100644 index 0000000..5617f4e --- /dev/null +++ b/tests/integration/jobs.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, beforeAll } from "vitest"; + +// Integration tests for the three promises of the data model +// (docs/specs/jobs/data-model.md): scoping, soft delete, and +// history-rides-the-change. Real queries against a REAL Postgres — +// PGlite, in memory, inside this test process. + +beforeAll(async () => { + process.env.PGLITE_DATA_DIR = "memory://"; + delete process.env.DATABASE_URL; + + const { prisma } = await import("@project/db"); + await prisma.user.create({ data: { id: "test-user", name: "Test User" } }); + await prisma.user.create({ data: { id: "other-user", name: "Somebody Else" } }); +}, 30000); + +describe("promise 3 — history rides the change", () => { + it("creates a job WITH its CREATED event, transactionally", async () => { + const { createJob } = await import("@project/domain"); + const { prisma } = await import("@project/db"); + + const job = await createJob("test-user", { + company: "Datadog", + title: "Software Engineer", + status: "APPLIED", + notes: "", + }); + expect(job.status).toBe("APPLIED"); + + const events = await prisma.jobEvent.findMany({ where: { jobId: job.id } }); + expect(events).toHaveLength(1); + expect(events[0].type).toBe("CREATED"); + expect(events[0].toStatus).toBe("APPLIED"); + }); +}); + +describe("promise 1 — every query is scoped", () => { + it("lists only the requesting user's jobs", async () => { + const { createJob, listJobs } = await import("@project/domain"); + + await createJob("other-user", { company: "NotYours Inc", title: "Theirs", status: "APPLIED", notes: "" }); + + const mine = await listJobs("test-user"); + expect(mine.map((j) => j.company)).toContain("Datadog"); + expect(mine.map((j) => j.company)).not.toContain("NotYours Inc"); + }); + + it("treats a foreign job id exactly like a missing one", async () => { + const { createJob, getJob } = await import("@project/domain"); + + const theirs = await createJob("other-user", { company: "Ramp", title: "SWE", status: "APPLIED", notes: "" }); + expect(await getJob(theirs.id, "test-user")).toBeNull(); + expect(await getJob("no-such-id", "test-user")).toBeNull(); + }); +}); + +describe("promise 2 — soft delete hides, never destroys", () => { + it("hides soft-deleted jobs from list and get", async () => { + const { createJob, listJobs, getJob } = await import("@project/domain"); + const { prisma } = await import("@project/db"); + + const doomed = await createJob("test-user", { company: "Doomed Co", title: "Ghost", status: "APPLIED", notes: "" }); + await prisma.job.update({ where: { id: doomed.id }, data: { deletedAt: new Date() } }); + + expect((await listJobs("test-user")).map((j) => j.company)).not.toContain("Doomed Co"); + expect(await getJob(doomed.id, "test-user")).toBeNull(); + + // …but the row still exists — soft delete destroys nothing. + const raw = await prisma.job.findUnique({ where: { id: doomed.id } }); + expect(raw?.deletedAt).not.toBeNull(); + }); +}); + +describe("boundary validation", () => { + it("rejects invalid input before it reaches the database", async () => { + const { CreateJob } = await import("@project/domain"); + + expect(CreateJob.safeParse({ company: "", title: "SWE" }).success).toBe(false); + expect( + CreateJob.safeParse({ company: "A", title: "B", salaryMin: 90_000, salaryMax: 80_000 }).success + ).toBe(false); + const ok = CreateJob.safeParse({ company: "A", title: "B" }); + expect(ok.success).toBe(true); + if (ok.success) expect(ok.data.status).toBe("APPLIED"); + }); +});