Skip to content
Merged
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
29 changes: 28 additions & 1 deletion .github/workflows/dump.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ on:
workflow_dispatch:

permissions:
contents: write
contents: read

concurrency:
group: dump
Expand All @@ -15,6 +15,8 @@ concurrency:
jobs:
dump:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
Expand Down Expand Up @@ -61,3 +63,28 @@ jobs:
overwrite_files: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

notify:
needs: dump
if: failure()
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
issues: write
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
steps:
- shell: bash
run: |
gh label create dump-failure --color B60205 --force
body="Daily dump failed on ${GITHUB_SHA::7}: $RUN_URL (the site keeps serving the previous dump-latest)."
n=$(gh issue list --state open --label dump-failure --json number --jq '.[0].number // empty')
if [ -n "$n" ]; then
gh issue comment "$n" --body "$body"
gh issue edit "$n" --add-assignee "$GITHUB_REPOSITORY_OWNER"
else
gh issue create --title "Dump failed ($(date -u +%F))" --body "$body" \
--label dump-failure --assignee "$GITHUB_REPOSITORY_OWNER"
fi
75 changes: 32 additions & 43 deletions .github/workflows/ingest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ concurrency:
group: ingest
cancel-in-progress: false

permissions:
contents: read

jobs:
ingest:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -51,46 +54,32 @@ jobs:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
OPENINTERN_BROWSER: "1"
run: pnpm ingest
- name: Notify on failure
if: failure()
continue-on-error: true
uses: actions/github-script@v9
with:
script: |
const title = `Ingest failed (${new Date().toISOString().slice(0, 10)})`;
const body = [
`Daily ingest workflow failed on \`${context.sha.slice(0, 7)}\`.`,
"",
`Run: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
"",
"Common causes: missing `DATABASE_URL` secret, Neon unreachable, or every company fetch failed.",
"Check `/health` for per-company errors after a successful run.",
].join("\n");
try {
const issues = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: "open",
labels: "ingest-failure",
per_page: 5,
});
const existing = issues.data.find((i) => i.title.startsWith("Ingest failed"));
if (existing) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existing.number,
body,
});
} else {
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title,
body,
labels: ["ingest-failure"],
});
}
} catch (err) {
core.warning(`Could not open/comment ingest-failure issue (permissions?): ${err}`);
}

# Separate job so the token that can open issues never sits next to
# DATABASE_URL. Assigning the owner makes GitHub Mobile push the alert.
notify:
needs: ingest
if: failure()
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
issues: write
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
steps:
- shell: bash
run: |
gh label create ingest-failure --color B60205 --force
body="Daily ingest failed on ${GITHUB_SHA::7} (attempt $GITHUB_RUN_ATTEMPT): $RUN_URL

Common causes: Neon unreachable or over quota, missing DATABASE_URL, or every company fetch failed."
n=$(gh issue list --state open --label ingest-failure --json number --jq '.[0].number // empty')
if [ -n "$n" ]; then
gh issue comment "$n" --body "$body"
gh issue edit "$n" --add-assignee "$GITHUB_REPOSITORY_OWNER"
else
gh issue create --title "Ingest failed ($(date -u +%F))" --body "$body" \
--label ingest-failure --assignee "$GITHUB_REPOSITORY_OWNER"
fi
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"@openintern/db": "workspace:*",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"next": "16.3.0-canary.93",
"next": "16.4.2",
"postgres": "^3.4.5",
"react": "^19.0.0",
"react-dom": "^19.0.0",
Expand Down
31 changes: 30 additions & 1 deletion apps/web/src/app/api/v1/companies/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { asc, eq, sql } from "drizzle-orm";
import { companies, jobs, publicJobSql } from "@openintern/db";
import { apiDbError } from "@/lib/api-error";
import { getDb } from "@/lib/db";
import { liveDumpJobs, loadJobsDump } from "@/lib/job-dump";
import { clientIp, rateLimit } from "@/lib/rate-limit";

export const dynamic = "force-dynamic";
Expand Down Expand Up @@ -41,6 +42,34 @@ export async function GET(request: Request) {

return NextResponse.json({ data: rows, total: rows.length });
} catch (err) {
return apiDbError(err);
console.warn("[api] companies: database unavailable, falling back to dump:", err);
try {
const dump = await loadJobsDump();
const bySlug = new Map<string, { name: string; slug: string; ats: string; active_jobs: number }>();
for (const job of liveDumpJobs(dump)) {
const c = bySlug.get(job.company.slug) ?? { ...job.company, active_jobs: 0 };
c.active_jobs += 1;
bySlug.set(job.company.slug, c);
}
// The dump has no company ids or URLs; only companies with live jobs appear.
const rows = [...bySlug.values()]
.sort((a, b) => a.name.localeCompare(b.name))
.map((c) => ({
id: c.slug,
name: c.name,
slug: c.slug,
ats: c.ats,
careers_url: null,
website_url: null,
active: true,
active_jobs: c.active_jobs,
}));
return NextResponse.json(
{ data: rows, total: rows.length },
{ headers: { "X-Data-Source": "dump", "X-Dump-Generated-At": dump.generatedAt } },
);
} catch {
return apiDbError(err);
}
}
}
2 changes: 1 addition & 1 deletion apps/web/src/app/api/v1/health/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ export async function GET() {
},
funnel_totals_latest_per_company: funnelTotals,
recent_ingest_runs: mapped,
});
}, { headers: { "Cache-Control": "public, s-maxage=300, stale-while-revalidate=60" } });
} catch (err) {
return apiDbError(err);
}
Expand Down
40 changes: 38 additions & 2 deletions apps/web/src/app/api/v1/jobs/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { and, eq } from "drizzle-orm";
import { companies, jobs } from "@openintern/db";
import { apiDbError } from "@/lib/api-error";
import { getDb } from "@/lib/db";
import { isPublicDumpJob, loadJobsDump } from "@/lib/job-dump";
import { publicJobSql } from "@/lib/freshness";
import { clientIp, rateLimit } from "@/lib/rate-limit";

Expand All @@ -18,8 +19,8 @@ export async function GET(
return NextResponse.json({ error: "Rate limit exceeded" }, { status: 429 });
}

const { id } = await context.params;
try {
const { id } = await context.params;
const db = getDb();
const [row] = await db
.select({
Expand Down Expand Up @@ -66,6 +67,41 @@ export async function GET(
},
});
} catch (err) {
return apiDbError(err);
console.warn("[api] job: database unavailable, falling back to dump:", err);
try {
const dump = await loadJobsDump();
const job = dump.jobs.find((j) => j.id.toLowerCase() === id.toLowerCase());
const headers = { "X-Data-Source": "dump", "X-Dump-Generated-At": dump.generatedAt };
if (!job || !isPublicDumpJob(job)) {
return NextResponse.json({ error: "Not found" }, { status: 404, headers });
}
return NextResponse.json(
{
data: {
id: job.id,
title: job.title,
locations: job.locations ?? [],
apply_url: job.apply_url,
excerpt: job.excerpt,
terms: job.terms ?? [],
term_years: job.term_years ?? [],
duration_months: job.duration_months ?? [],
cohort_year: job.cohort_year,
roles: job.roles ?? [],
regions: job.regions ?? [],
is_remote: job.is_remote,
is_active: true,
source: job.source,
posted_at: job.posted_at,
first_seen_at: job.first_seen_at,
last_seen_at: job.last_seen_at,
company: { ...job.company, careers_url: null, website_url: null },
},
},
{ headers },
);
} catch {
return apiDbError(err);
}
}
}
2 changes: 1 addition & 1 deletion apps/web/src/app/health/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { companies, ingestRuns, jobs } from "@openintern/db";
import { getDb } from "@/lib/db";
import { parseFunnel, sumFunnels } from "@/lib/ingest-health";

export const dynamic = "force-dynamic";
export const revalidate = 300;

export default async function HealthPage() {
let error: string | null = null;
Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/app/jobs/[company]/[jobSlug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { JobDetailView } from "@/components/JobDetailView";
import { jobPath, parseIdPrefixFromJobSlug } from "@/lib/job-slug";
import { StaleDataBanner } from "@/components/StaleDataBanner";
import { loadJobByCompanyAndIdPrefix } from "@/lib/load-job";

export const dynamic = "force-dynamic";
Expand Down Expand Up @@ -59,8 +60,13 @@ export default async function JobSlugDetailPage({
const idPrefix = parseIdPrefixFromJobSlug(jobSlug);
if (!idPrefix) notFound();

const { job } = await loadJobByCompanyAndIdPrefix(company, idPrefix);
const { job, dataSource } = await loadJobByCompanyAndIdPrefix(company, idPrefix);
if (!job) notFound();

return <JobDetailView job={job} />;
return (
<>
{dataSource.kind === "dump" ? <StaleDataBanner generatedAt={dataSource.generatedAt} /> : null}
<JobDetailView job={job} />
</>
);
}
19 changes: 17 additions & 2 deletions apps/web/src/app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { and, eq } from "drizzle-orm";
import { companies, jobs } from "@openintern/db";
import { getDb } from "@/lib/db";
import { publicJobSql } from "@/lib/freshness";
import { liveDumpJobs, loadJobsDump } from "@/lib/job-dump";
import { jobPath } from "@/lib/job-slug";

export const dynamic = "force-dynamic";
Expand All @@ -17,7 +18,6 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
{ url: `${base}/jobs`, changeFrequency: "hourly", priority: 0.95 },
{ url: `${base}/docs`, changeFrequency: "weekly", priority: 0.8 },
{ url: `${base}/privacy`, changeFrequency: "yearly", priority: 0.3 },
{ url: `${base}/health`, changeFrequency: "daily", priority: 0.5 },
{ url: `${base}/vs/intern-insider`, changeFrequency: "monthly", priority: 0.6 },
];

Expand All @@ -44,6 +44,21 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
})),
];
} catch {
return staticEntries;
try {
const dump = await loadJobsDump();
return [
...staticEntries,
...liveDumpJobs(dump)
.slice(0, JOB_SITEMAP_LIMIT)
.map((j) => ({
url: `${base}${jobPath(j.company.slug, j.title, j.id)}`,
lastModified: new Date(j.last_seen_at),
changeFrequency: "daily" as const,
priority: 0.7,
})),
];
} catch {
return staticEntries;
}
}
}
5 changes: 2 additions & 3 deletions apps/web/src/lib/api-error.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { NextResponse } from "next/server";

export function apiDbError(err: unknown) {
const message = err instanceof Error ? err.message : "Database unavailable";
console.error("[api] database error:", err);
return NextResponse.json(
{ error: "Database unavailable", detail: message },
{ status: 503, headers: { "Retry-After": "3600" } },
{ error: "Database unavailable" },
{ status: 503, headers: { "Retry-After": "3600", "Cache-Control": "no-store" } },
);
}
Loading
Loading