Jobber/orchestrator/src/server/services/rxresume-client.ts
Shaheer Sarfaraz 3da5ea35b4
Deduplicate shared helpers and enforce aliased imports (#228)
* 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
2026-02-22 16:13:52 +00:00

464 lines
13 KiB
TypeScript

// rxresume-client.ts
// Low-level HTTP client for the RxResume v4 API.
// - Handles login, token caching, and cookie-based auth.
// - Used by rxresume-v4.ts to provide a higher-level service surface.
// - The v5 client should be a drop-in replacement in the future.
import type { ResumeData } from "@shared/rxresume-schema";
import { normalizeWhitespace } from "@shared/utils/string";
type AnyObj = Record<string, unknown>;
const MAX_ERROR_SNIPPET = 300;
const TOKEN_COOKIE_NAMES = [
"accessToken",
"access_token",
"token",
"authToken",
"auth_token",
"Authentication",
"Refresh",
];
function extractTokenFromCookies(
rawCookies: string | string[] | null,
): string | null {
if (!rawCookies) return null;
const combined = Array.isArray(rawCookies)
? rawCookies.join("; ")
: rawCookies;
for (const name of TOKEN_COOKIE_NAMES) {
const match = new RegExp(`${name}=([^;]+)`).exec(combined);
if (match?.[1]) return match[1];
}
return null;
}
function buildAuthHeaders(token: string): Record<string, string> {
return {
Authorization: `Bearer ${token}`,
Cookie: `Authentication=${token}`,
};
}
export type RxResumeResume = {
id: string;
name: string;
title: string;
slug?: string;
data?: ResumeData;
[key: string]: unknown;
};
export type VerifyResult =
| { ok: true }
| {
ok: false;
status: number;
// Message is best-effort; server responses vary.
message?: string;
// Some APIs include error codes/details.
details?: unknown;
};
interface CachedToken {
token: string;
expiresAt: number; // Unix timestamp
}
// Token cache: key is hash of baseURL + identifier
const tokenCache = new Map<string, CachedToken>();
// Default token TTL: 50 minutes (JWT tokens typically expire in 1 hour)
const DEFAULT_TOKEN_TTL_MS = 50 * 60 * 1000;
export class RxResumeClient {
private readonly tokenTtlMs: number;
constructor(
private readonly baseURL = "https://v4.rxresu.me",
options?: { tokenTtlMs?: number },
) {
this.tokenTtlMs = options?.tokenTtlMs ?? DEFAULT_TOKEN_TTL_MS;
}
/**
* Generate a cache key for token storage.
* Uses a simple hash of baseURL + identifier.
*/
private getCacheKey(identifier: string): string {
return `${this.baseURL}:${identifier}`;
}
/**
* Get a valid auth token, using cached token if available and not expired.
* This is the preferred way to get a token for API calls.
*/
async getToken(identifier: string, password: string): Promise<string> {
const cacheKey = this.getCacheKey(identifier);
const cached = tokenCache.get(cacheKey);
// Return cached token if it exists and hasn't expired
if (cached && cached.expiresAt > Date.now()) {
return cached.token;
}
// Login to get a new token
const token = await this.login(identifier, password);
// Cache the token
tokenCache.set(cacheKey, {
token,
expiresAt: Date.now() + this.tokenTtlMs,
});
return token;
}
/**
* Clear cached token for a specific identifier.
* Useful when a token becomes invalid (e.g., 401 response).
*/
clearCachedToken(identifier: string): void {
const cacheKey = this.getCacheKey(identifier);
tokenCache.delete(cacheKey);
}
/**
* Clear all cached tokens.
*/
static clearAllCachedTokens(): void {
tokenCache.clear();
}
/**
* Execute an API operation with automatic token refresh on 401.
* If the operation fails with a 401, clears the cached token, gets a new one, and retries once.
*
* @param identifier - The user identifier (email)
* @param password - The user password
* @param operation - A function that takes a token and performs the API call
* @returns The result of the operation
*/
async withAutoRefresh<T>(
identifier: string,
password: string,
operation: (token: string) => Promise<T>,
): Promise<T> {
const token = await this.getToken(identifier, password);
try {
return await operation(token);
} catch (error) {
// Check if this is a 401 error
const message = error instanceof Error ? error.message : "";
const isAuthError =
/HTTP\s*401/i.test(message) ||
/Unauthorized/i.test(message) ||
/Unauthenticated/i.test(message);
if (isAuthError) {
// Clear the cached token and retry with a fresh one
this.clearCachedToken(identifier);
const freshToken = await this.getToken(identifier, password);
return await operation(freshToken);
}
// Re-throw non-401 errors
throw error;
}
}
/**
* Verify a username/password combo WITHOUT persisting a logged-in session.
*
* Reality check:
* - Most sites only expose "verify" by attempting login.
* - This method does a stateless request to test credentials.
*/
static async verifyCredentials(
identifier: string,
password: string,
baseURL = "https://v4.rxresu.me",
): Promise<VerifyResult> {
try {
const res = await fetch(`${baseURL}/api/auth/login`, {
method: "POST",
headers: {
Accept: "application/json, text/plain, */*",
"Content-Type": "application/json",
},
body: JSON.stringify({ identifier, password }),
// No credentials mode - we don't want to persist cookies
});
if (res.ok) return { ok: true };
// Best-effort message extraction
let data: AnyObj = {};
try {
const text = await res.text();
data = text ? (JSON.parse(text) as AnyObj) : {};
} catch {
// Ignore JSON parse errors
}
const message =
(typeof data === "string" ? data : undefined) ??
(typeof data?.message === "string" ? data.message : undefined) ??
(typeof data?.error === "string" ? data.error : undefined) ??
(typeof data?.statusMessage === "string"
? data.statusMessage
: undefined);
return { ok: false, status: res.status, message, details: data };
} catch (error) {
return {
ok: false,
status: 0,
message: error instanceof Error ? error.message : "Network error",
details: error,
};
}
}
// ─────────────────────────────────────────────────────────────────────────────
// RESERVED FOR FUTURE USE
// The following methods support full resume lifecycle management via the
// RxResume API. They are not currently used but are kept for future features.
// ─────────────────────────────────────────────────────────────────────────────
/**
* POST /api/auth/login
* Returns the auth token on success.
*/
async login(identifier: string, password: string): Promise<string> {
const res = await fetch(`${this.baseURL}/api/auth/login`, {
method: "POST",
headers: {
Accept: "application/json, text/plain, */*",
"Content-Type": "application/json",
},
body: JSON.stringify({ identifier, password }),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(
`Login failed: HTTP ${res.status} ${sanitizeResponseSnippet(text)}`,
);
}
const data = (await res.json()) as AnyObj;
// The API may return the token in different ways
let token =
data?.accessToken ??
data?.access_token ??
data?.token ??
(data?.data as AnyObj)?.accessToken ??
(data?.data as AnyObj)?.token;
if (!token) {
const setCookieHeader = res.headers.get("set-cookie");
// getSetCookie is a newer method in standard Fetch API, but might not be in all environments
// biome-ignore lint/suspicious/noExplicitAny: headers may not have getSetCookie in all types
const setCookieArray = (res.headers as any).getSetCookie?.() as
| string[]
| undefined;
token = extractTokenFromCookies(setCookieArray ?? setCookieHeader);
}
if (!token || typeof token !== "string") {
throw new Error(
"Login succeeded but could not locate access token in response.",
);
}
return token;
}
/**
* POST /api/resume/import
*/
async create(
resumeData: unknown,
token: string,
options?: { title?: string; slug?: string },
): Promise<string> {
const payload: AnyObj = { data: resumeData };
if (options?.title) payload.title = options.title;
if (options?.slug) payload.slug = options.slug;
const res = await fetch(`${this.baseURL}/api/resume/import`, {
method: "POST",
headers: {
Accept: "application/json, text/plain, */*",
"Content-Type": "application/json",
...buildAuthHeaders(token),
},
body: JSON.stringify(payload),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(
`Create failed: HTTP ${res.status} ${sanitizeResponseSnippet(text)}`,
);
}
const d = (await res.json()) as AnyObj;
const id =
d?.id ??
(d?.data as AnyObj)?.id ??
(d?.resume as AnyObj)?.id ??
(d?.result as AnyObj)?.id ??
(d?.payload as AnyObj)?.id ??
((d?.data as AnyObj)?.resume as AnyObj)?.id;
if (!id || typeof id !== "string") {
throw new Error(
"Create succeeded but could not locate resume id in response.",
);
}
return id;
}
/**
* GET /api/resume/print/:id
* Returns the print URL from the response.
*/
async print(resumeId: string, token: string): Promise<string> {
const res = await fetch(
`${this.baseURL}/api/resume/print/${encodeURIComponent(resumeId)}`,
{
method: "GET",
headers: {
Accept: "application/json, text/plain, */*",
...buildAuthHeaders(token),
},
},
);
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(
`Print failed: HTTP ${res.status} ${sanitizeResponseSnippet(text)}`,
);
}
const d = (await res.json()) as AnyObj;
const url =
d?.url ??
d?.href ??
(d?.data as AnyObj)?.url ??
(d?.data as AnyObj)?.href ??
(d?.result as AnyObj)?.url ??
(d?.result as AnyObj)?.href;
if (!url || typeof url !== "string") {
throw new Error("Print succeeded but could not locate URL in response.");
}
return url;
}
/**
* DELETE /api/resume/:id
*/
async delete(resumeId: string, token: string): Promise<void> {
const res = await fetch(
`${this.baseURL}/api/resume/${encodeURIComponent(resumeId)}`,
{
method: "DELETE",
headers: {
Accept: "application/json, text/plain, */*",
...buildAuthHeaders(token),
},
},
);
if (!res.ok && res.status !== 204) {
const text = await res.text().catch(() => "");
throw new Error(
`Delete failed: HTTP ${res.status} ${sanitizeResponseSnippet(text)}`,
);
}
}
private normalizeResume(raw: AnyObj): RxResumeResume {
const id = typeof raw.id === "string" ? raw.id : "";
const title =
typeof raw.title === "string"
? raw.title
: typeof raw.name === "string"
? raw.name
: "Untitled";
const name = typeof raw.name === "string" ? raw.name : title;
const slug = typeof raw.slug === "string" ? raw.slug : undefined;
const data =
raw.data && typeof raw.data === "object"
? (raw.data as ResumeData)
: undefined;
return {
...raw,
id,
title,
name,
slug,
data,
};
}
/**
* GET /api/resume
* List all resumes for the authenticated user.
*/
async list(token: string): Promise<RxResumeResume[]> {
const res = await fetch(`${this.baseURL}/api/resume`, {
method: "GET",
headers: {
Accept: "application/json, text/plain, */*",
...buildAuthHeaders(token),
},
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(
`List resumes failed: HTTP ${res.status} ${sanitizeResponseSnippet(text)}`,
);
}
const data = (await res.json()) as AnyObj | AnyObj[];
// API may return array directly or wrapped in data/resumes
const resumes = Array.isArray(data)
? data
: ((data?.data as AnyObj[]) ?? (data?.resumes as AnyObj[]) ?? []);
return resumes
.filter((resume) => resume && typeof resume === "object")
.map((resume) => this.normalizeResume(resume as AnyObj));
}
/**
* GET /api/resume
* Fetch a single resume by ID (via list filtering).
*/
async get(resumeId: string, token: string): Promise<RxResumeResume> {
const resumes = await this.list(token);
const resume = resumes.find((item) => item.id === resumeId);
if (!resume) {
throw new Error(`Resume not found: ${resumeId}`);
}
return resume;
}
}
function sanitizeResponseSnippet(text: string): string {
if (!text) return "";
const compact = normalizeWhitespace(text);
return compact.slice(0, MAX_ERROR_SNIPPET);
}