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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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/<basename>)

# If you are running it on a remote server/homelab, you must update timezone otherwise activities times may shift
TZ=America/Edmonton

Expand Down
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*.sh text eol=lf
Dockerfile text eol=lf
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
519 changes: 519 additions & 0 deletions PRD.md

Large diffs are not rendered by default.

25 changes: 3 additions & 22 deletions __tests__/JobsActivityCard.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,6 @@ vi.mock("next-themes", () => ({
useTheme: () => ({ resolvedTheme: "light" }),
}));

vi.mock("@nivo/pie", () => ({
Pie: (props: any) => (
<div data-testid="donut">
{props.data.map((slice: any) => (
<div key={slice.id} data-testid={`slice-${slice.id}`}>
{slice.label}:{slice.value}:{slice.color}
{slice.breakdown?.length
? `:${slice.breakdown
.map((a: any) => `${a.label}=${a.hours}`)
.join(",")}`
: ""}
</div>
))}
</div>
),
}));

describe("JobsActivityCard", () => {
const user = userEvent.setup();

Expand Down Expand Up @@ -83,16 +66,14 @@ describe("JobsActivityCard", () => {
expect(within(total).getByText("16 jobs")).toBeInTheDocument();
});

it("labels the slices on the chart instead of in a legend", () => {
it("lists the slices in a legend under the donut", () => {
render(<JobsActivityCard data={data} />);

expect(
screen.queryByTestId("jobs-activity-legend"),
).not.toBeInTheDocument();
expect(screen.getByTestId("jobs-activity-legend")).toBeInTheDocument();
expect(screen.getByTestId("donut")).toBeInTheDocument();
});

it("feeds the same slices to the donut", () => {
it("feeds the same slices to the legend", () => {
render(<JobsActivityCard data={data} />);

expect(screen.getByTestId("slice-Jobsync")).toHaveTextContent(
Expand Down
213 changes: 213 additions & 0 deletions __tests__/bucket-sync.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
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,
s3Delete,
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/<basename>", () => {
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<string, string> }[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init: RequestInit) => {
calls.push({ url, method: init.method ?? "GET", headers: init.headers as Record<string, string> });
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("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");
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);
});
});
9 changes: 9 additions & 0 deletions __tests__/job.actions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
},
},
});
});
Expand Down Expand Up @@ -999,6 +1004,8 @@ describe("jobActions", () => {
statusId: jobData.status,
jobSourceId: jobData.source,
salaryRange: jobData.salaryRange,
fundingStatus: null,
workspaceId: null,
createdAt: jobData.createdAt,
dueDate: jobData.dueDate,
appliedDate: jobData.dateApplied,
Expand Down Expand Up @@ -1030,6 +1037,8 @@ describe("jobActions", () => {
statusId: jobData.status,
jobSourceId: jobData.source,
salaryRange: jobData.salaryRange,
fundingStatus: null,
workspaceId: null,
createdAt: jobData.createdAt,
dueDate: jobData.dueDate,
description: jobData.jobDescription,
Expand Down
50 changes: 50 additions & 0 deletions __tests__/mcp-log-outreach.spec.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn> };
contact: { findFirst: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn> };
outreach: { create: ReturnType<typeof vi.fn> };
};

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/);
});
});
24 changes: 24 additions & 0 deletions __tests__/outreach-follow-up.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading