* Deduplicate string cleanup helpers and not-found responses * Enforce aliased imports for infra and shared modules * Enforce @client/@server aliases for deep relative imports * Deduplicate visa sponsor and location filter definitions * Use shared city filter export in extractor location checks
178 lines
4.9 KiB
TypeScript
178 lines
4.9 KiB
TypeScript
import { badRequest, notFound } from "@infra/errors";
|
|
import { asyncRoute, fail, ok } from "@infra/http";
|
|
import * as jobsRepo from "@server/repositories/jobs";
|
|
import {
|
|
getJobTracerLinksAnalytics,
|
|
getTracerAnalytics,
|
|
getTracerReadiness,
|
|
} from "@server/services/tracer-links";
|
|
import { type Request, type Response, Router } from "express";
|
|
import { z } from "zod";
|
|
|
|
export const tracerLinksRouter = Router();
|
|
|
|
const querySchema = z.object({
|
|
jobId: z.string().trim().min(1).max(255).optional(),
|
|
from: z.coerce.number().int().min(0).optional(),
|
|
to: z.coerce.number().int().min(0).optional(),
|
|
includeBots: z
|
|
.preprocess((value) => {
|
|
if (value === undefined) return false;
|
|
if (typeof value === "boolean") return value;
|
|
const lowered = String(value).trim().toLowerCase();
|
|
return lowered === "1" || lowered === "true" || lowered === "yes";
|
|
}, z.boolean())
|
|
.optional(),
|
|
limit: z.coerce.number().int().min(1).max(500).optional(),
|
|
});
|
|
|
|
const paramsSchema = z.object({
|
|
jobId: z.string().trim().min(1).max(255),
|
|
});
|
|
|
|
const readinessQuerySchema = z.object({
|
|
force: z
|
|
.preprocess((value) => {
|
|
if (value === undefined) return false;
|
|
if (typeof value === "boolean") return value;
|
|
const lowered = String(value).trim().toLowerCase();
|
|
return lowered === "1" || lowered === "true" || lowered === "yes";
|
|
}, z.boolean())
|
|
.optional(),
|
|
});
|
|
|
|
function assertTimeRange(
|
|
from: number | undefined,
|
|
to: number | undefined,
|
|
): string | null {
|
|
if (typeof from === "number" && typeof to === "number" && from > to) {
|
|
return "`from` must be less than or equal to `to`.";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolveRequestOrigin(req: Request): string | null {
|
|
const configuredBaseUrl = process.env.JOBOPS_PUBLIC_BASE_URL?.trim();
|
|
if (configuredBaseUrl) {
|
|
try {
|
|
const parsed = new URL(configuredBaseUrl);
|
|
if (parsed.protocol && parsed.host) {
|
|
return `${parsed.protocol}//${parsed.host}`;
|
|
}
|
|
} catch {
|
|
// Ignore invalid env and fall back to request-derived origin.
|
|
}
|
|
}
|
|
|
|
const trustProxy = Boolean(req.app?.get("trust proxy"));
|
|
let protocol = (req.protocol || "").trim();
|
|
let host = (req.header("host") || "").trim();
|
|
|
|
if (trustProxy) {
|
|
const forwardedProto =
|
|
req.header("x-forwarded-proto")?.split(",")[0]?.trim() ?? "";
|
|
const forwardedHost =
|
|
req.header("x-forwarded-host")?.split(",")[0]?.trim() ?? "";
|
|
if (forwardedProto) protocol = forwardedProto;
|
|
if (forwardedHost) host = forwardedHost;
|
|
}
|
|
|
|
if (!host || !protocol) return null;
|
|
return `${protocol}://${host}`;
|
|
}
|
|
|
|
tracerLinksRouter.get(
|
|
"/readiness",
|
|
asyncRoute(async (req: Request, res: Response) => {
|
|
const parsed = readinessQuerySchema.safeParse(req.query);
|
|
if (!parsed.success) {
|
|
fail(res, badRequest(parsed.error.message, parsed.error.flatten()));
|
|
return;
|
|
}
|
|
|
|
const readiness = await getTracerReadiness({
|
|
requestOrigin: resolveRequestOrigin(req),
|
|
force: parsed.data.force ?? false,
|
|
});
|
|
|
|
ok(res, readiness);
|
|
}),
|
|
);
|
|
|
|
tracerLinksRouter.get(
|
|
"/analytics",
|
|
asyncRoute(async (req: Request, res: Response) => {
|
|
const parsed = querySchema.safeParse(req.query);
|
|
if (!parsed.success) {
|
|
fail(res, badRequest(parsed.error.message, parsed.error.flatten()));
|
|
return;
|
|
}
|
|
|
|
const rangeError = assertTimeRange(parsed.data.from, parsed.data.to);
|
|
if (rangeError) {
|
|
fail(res, badRequest(rangeError));
|
|
return;
|
|
}
|
|
|
|
const analytics = await getTracerAnalytics({
|
|
jobId: parsed.data.jobId ?? null,
|
|
from: parsed.data.from ?? null,
|
|
to: parsed.data.to ?? null,
|
|
includeBots: parsed.data.includeBots ?? false,
|
|
limit: parsed.data.limit ?? 20,
|
|
});
|
|
|
|
ok(res, analytics);
|
|
}),
|
|
);
|
|
|
|
tracerLinksRouter.get(
|
|
"/jobs/:jobId",
|
|
asyncRoute(async (req: Request, res: Response) => {
|
|
const parsedParams = paramsSchema.safeParse(req.params);
|
|
if (!parsedParams.success) {
|
|
fail(
|
|
res,
|
|
badRequest(parsedParams.error.message, parsedParams.error.flatten()),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const parsedQuery = querySchema.safeParse(req.query);
|
|
if (!parsedQuery.success) {
|
|
fail(
|
|
res,
|
|
badRequest(parsedQuery.error.message, parsedQuery.error.flatten()),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const rangeError = assertTimeRange(
|
|
parsedQuery.data.from,
|
|
parsedQuery.data.to,
|
|
);
|
|
if (rangeError) {
|
|
fail(res, badRequest(rangeError));
|
|
return;
|
|
}
|
|
|
|
const job = await jobsRepo.getJobById(parsedParams.data.jobId);
|
|
if (!job) {
|
|
fail(res, notFound("Job not found"));
|
|
return;
|
|
}
|
|
|
|
const analytics = await getJobTracerLinksAnalytics({
|
|
jobId: job.id,
|
|
title: job.title,
|
|
employer: job.employer,
|
|
tracerLinksEnabled: job.tracerLinksEnabled,
|
|
from: parsedQuery.data.from ?? null,
|
|
to: parsedQuery.data.to ?? null,
|
|
includeBots: parsedQuery.data.includeBots ?? false,
|
|
});
|
|
|
|
ok(res, analytics);
|
|
}),
|
|
);
|