From 712a4e57f389a3b7d4a9dc6751a0b8883216e31f Mon Sep 17 00:00:00 2001 From: Sai-karthik Date: Wed, 15 Jul 2026 19:23:02 +0000 Subject: [PATCH 1/3] feat: expose live career report data --- package.json | 1 + scripts/career-report.test.ts | 185 +++++++++++ src/v1/analytics/README.md | 31 ++ src/v1/analytics/analytics-routes.ts | 71 ++++- src/v1/analytics/career-report.ts | 358 ++++++++++++++++++++++ src/v1/analytics/home-insight-snapshot.ts | 35 ++- 6 files changed, 678 insertions(+), 3 deletions(-) create mode 100644 scripts/career-report.test.ts create mode 100644 src/v1/analytics/career-report.ts diff --git a/package.json b/package.json index 49f2f33..946e5ce 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "test:qscore-raw-events": "tsx scripts/qscore-raw-event-contract.test.ts", "test:rq-score-contract": "tsx scripts/rq-score-contract.test.ts", "test:home-insight-snapshot": "tsx scripts/home-insight-snapshot.test.ts", + "test:career-report": "tsx scripts/career-report.test.ts", "test:onboarding-read": "tsx scripts/onboarding-rev10-read.test.ts", "test:interview-user-identity": "tsx scripts/interview-user-identity.test.ts", "test:ws-ticket": "tsx scripts/ws-ticket.test.ts", diff --git a/scripts/career-report.test.ts b/scripts/career-report.test.ts new file mode 100644 index 0000000..dd82fc3 --- /dev/null +++ b/scripts/career-report.test.ts @@ -0,0 +1,185 @@ +import assert from "node:assert/strict"; + +import { + buildCareerReportProjection, + parseCareerReportDisplay, + type CareerReportActivity, +} from "../src/v1/analytics/career-report.js"; +import type { HomeInsightSnapshots, InsightSnapshot } from "../src/v1/analytics/home-insight-snapshot.js"; +import type { QscoreProxyResult } from "../src/services/qscore-proxy.js"; + +const categoryIds = [ + "learning_credentials", + "resume_presence", + "linkedin_presence", + "matching_activity", + "interview_performance", + "pathway_progress", + "roleplay_performance", + "engagement_consistency", + "goals_readiness", +]; + +function displayFixture() { + let signalIndex = 0; + const categories = categoryIds.map((id, categoryIndex) => { + const count = categoryIndex === categoryIds.length - 1 ? 3 : 8; + const signals = Array.from({ length: count }, () => { + const index = signalIndex++; + return { id: `signal.${index}`, label: `Signal ${index}`, present: index < 4, score: index < 4 ? 50 : 0 }; + }); + return { + id, + label: id, + input_ids: [`input_${categoryIndex}`], + weight_percent: 10, + score: signals.some((signal) => signal.present) ? 50 : 0, + active: signals.some((signal) => signal.present), + present_rules: signals.filter((signal) => signal.present).length, + total_rules: signals.length, + signals, + }; + }); + return { + version: "rq-score-display-v1", + registered_signal_count: 67, + active_signal_count: 4, + categories, + }; +} + +function metric(overrides: Partial = {}): InsightSnapshot { + return { + status: "empty", + source: "fixture", + value: 0, + unit: "count", + label: "Fixture", + updatedAt: null, + reason: "no_evidence", + detail: null, + ...overrides, + }; +} + +const snapshots: HomeInsightSnapshots = { + rqScore: metric({ status: "ready", source: "qscore.current", value: 61, reason: null }), + interview: metric({ source: "interview.page-state" }), + resume: metric({ status: "ready", source: "resume.state", value: 72, reason: null }), + opportunity: metric({ source: "matchmaking.scout-stats" }), + pathway: metric({ status: "unsupported", source: "qscore.breakdown.display", value: null, reason: "pathway_signal_contract_missing" }), + socialBrand: metric({ source: "social.state" }), + roleplay: metric({ source: "roleplay.page-state" }), + learning: metric({ source: "courses.course-state" }), + skillGap: metric({ source: "assessment.list" }), +}; + +const qscore: QscoreProxyResult = { + org_id: "growqr", + user_id: "00000000-0000-4000-8000-000000000000", + rq_score: 61, + iq_score: null, + eq_score: null, + sq_score: null, + profession: "", + formula_version: "v2", + formula_id: "fixture", + calculated_at: "2026-07-16T00:00:00Z", + quotients: {}, + breakdown: { display: displayFixture() }, + ledger_seq_from: null, + ledger_seq_to: null, +}; + +const activity: CareerReportActivity = { + windowDays: 14, + totalEvents: 0, + completedEvents: 0, + openedEvents: 0, + services: [], + opportunityEvents: 0, +}; + +const producerStatuses = { + interview: { status: "ready" as const, reason: null }, + roleplay: { status: "ready" as const, reason: null }, + resume: { status: "ready" as const, reason: null }, + linkedin: { status: "ready" as const, reason: null }, + matchmaking: { status: "ready" as const, reason: null }, + courses: { status: "ready" as const, reason: null }, + assessment: { status: "ready" as const, reason: null }, +}; + +const report = buildCareerReportProjection({ + generatedAt: "2026-07-16T01:00:00Z", + profile: { + status: "ready", + data: { + first_name: "Ada", + last_name: "Lovelace", + avatar_url: "https://images.example/ada.png", + updated_at: "2026-07-15T22:00:00Z", + preferences: { + onboarding: { + progress: { updated_at: "2026-07-15T23:00:00Z" }, + responses: { + target_role: "Platform Engineer", + target_field: "Technology", + experience_level: "3-5 years", + current_situation: "employed", + desired_outcomes: ["Move into platform engineering"], + career_barriers: [], + }, + }, + }, + }, + }, + qscore: { status: "ready", data: qscore }, + qscoreHistory: { status: "ready", data: [{ date: "2026-07-15", rq_score: 61 }] }, + snapshots, + producerStatuses, + activity, +}); + +assert.equal(report.contractVersion, "career-report-v1"); +assert.deepEqual(report.subject.displayName, { + status: "ready", + source: "user-service.profile", + value: "Ada Lovelace", + updatedAt: "2026-07-15T22:00:00Z", + reason: null, +}); +assert.equal(report.subject.targetRole.value, "Platform Engineer"); +assert.equal(report.subject.barriers.status, "empty"); +assert.deepEqual(report.subject.barriers.value, []); +assert.equal(report.rqScore.score.value, 61); +assert.equal(report.rqScore.display.status, "ready"); +assert.equal(report.rqScore.display.value?.registeredSignalCount, 67); +assert.equal(report.rqScore.display.value?.categories.length, 9); +assert.deepEqual(report.activity.value, activity); +assert.equal(report.sources.find((source) => source.id === "grow_events")?.status, "ready"); + +const malformed = structuredClone(displayFixture()); +malformed.registered_signal_count = 66; +assert.equal(parseCareerReportDisplay(malformed), null, "mislabelled canonical display must be rejected"); + +const unavailable = buildCareerReportProjection({ + profile: { status: "unavailable", reason: "profile_unavailable" }, + qscore: { status: "unavailable", reason: "request_failed" }, + qscoreHistory: { status: "unavailable", reason: "request_failed" }, + snapshots: Object.fromEntries( + Object.keys(snapshots).map((key) => [key, metric({ status: "unavailable", value: null, reason: "producer_unavailable" })]), + ) as HomeInsightSnapshots, + producerStatuses: Object.fromEntries( + Object.keys(producerStatuses).map((key) => [key, { status: "unavailable", reason: "producer_unavailable" }]), + ) as typeof producerStatuses, + activity, +}); +assert.equal(unavailable.subject.displayName.status, "unavailable"); +assert.equal(unavailable.subject.displayName.value, null); +assert.equal(unavailable.rqScore.score.status, "unavailable"); +assert.equal(unavailable.rqScore.score.value, null); +assert.equal(unavailable.rqScore.history.value, null); +assert.equal(unavailable.rqScore.display.value, null); + +console.log("career report contract: ok"); diff --git a/src/v1/analytics/README.md b/src/v1/analytics/README.md index e8fb056..6f8d4d8 100644 --- a/src/v1/analytics/README.md +++ b/src/v1/analytics/README.md @@ -78,3 +78,34 @@ single unavailable producer does not fail the full snapshot response. A direct producer failure may fall back only to an active, `ready` canonical qscore category. An inactive category with `no_category_evidence` never masks an outage as an empty numeric zero. + +## Career report data contract + +`GET /v1/analytics/career-report` is an authenticated, read-only aggregation +for the dashboard-owned fixed PDF template. It returns +`contractVersion: "career-report-v1"` with these additive top-level fields: + +- `subject`: canonical user-service profile and onboarding values. +- `rqScore`: the current score, producer history, and a strictly validated + `rq-score-display-v1` projection. +- `snapshots`: the existing nine Home Insight producer projections. +- `activity`: real GrowEvents counts for the previous 14 days. +- `sources`: status and timestamp metadata for every queried producer. + +Every field is status-bearing. `ready` carries a real producer value; `empty` +means a successful read confirmed no value or an empty array/count; +`unavailable` means the producer could not be read; and `unsupported` means the +expected producer contract is absent. Errors and missing integrations never +become numeric zeroes. + +The endpoint deliberately does not calculate labels, rankings, strongest or +weakest categories, recommendations, timelines, peer/market benchmarks, +profile scores, percentages, or narrative prose. It creates no database rows, +jobs, or stored reports. PDF layout and fixed explanatory labels remain owned +by the dashboard. + +Profile identity is read strictly from user-service `/api/v1/users/me` using +the authenticated request. The backend-mirror profile fallback is not used, +so internal user ids or synthetic service-local email addresses cannot leak +into the report. No email, token, link, secret, or internal credential is +included in the response. diff --git a/src/v1/analytics/analytics-routes.ts b/src/v1/analytics/analytics-routes.ts index fe5bf2f..fb8ada7 100644 --- a/src/v1/analytics/analytics-routes.ts +++ b/src/v1/analytics/analytics-routes.ts @@ -13,7 +13,16 @@ import { readQscoreFromService, readQscoreHistoryFromService, } from "../../services/qscore-proxy.js"; -import { buildHomeInsightSnapshots } from "./home-insight-snapshot.js"; +import { fetchUserProfile } from "../../services/user-profile.js"; +import { + buildHomeInsightSnapshots, + type HomeInsightProducerStatuses, +} from "./home-insight-snapshot.js"; +import { + buildCareerReportProjection, + type CareerReportActivity, + type CareerReportProfileRead, +} from "./career-report.js"; function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); @@ -148,6 +157,66 @@ export function v1AnalyticsRoutes() { return c.json(response); }); + app.get("/career-report", async (c) => { + const userId = c.get("userId"); + const profilePromise: Promise = fetchUserProfile(c.req.raw) + .then((data) => isRecord(data) + ? { status: "ready" as const, data } + : { status: "unavailable" as const, reason: "profile_response_invalid" as const }) + .catch(() => ({ status: "unavailable" as const, reason: "profile_unavailable" as const })); + const [qscoreRead, qscoreHistory, profile, recentEvents, counts] = await Promise.all([ + readQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID), + readQscoreHistoryFromService(userId, DEFAULT_QSCORE_ORG_ID), + profilePromise, + db + .select() + .from(growEvents) + .where(and(eq(growEvents.userId, userId), gte(growEvents.occurredAt, daysAgo(14)))) + .orderBy(desc(growEvents.occurredAt)) + .limit(100), + db + .select({ + total: sql`count(*)::int`, + completed: sql`count(*) filter (where ${growEvents.type} ilike '%completed%' or ${growEvents.type} ilike '%review_completed%')::int`, + opened: sql`count(*) filter (where ${growEvents.type} = 'task.opened' or ${growEvents.type} ilike '%started%')::int`, + }) + .from(growEvents) + .where(and(eq(growEvents.userId, userId), gte(growEvents.occurredAt, daysAgo(14)))), + ]); + let producerStatuses: HomeInsightProducerStatuses | null = null; + const snapshots = await buildHomeInsightSnapshots({ + userId, + qscore: qscoreRead, + qscoreHistory, + recentEvents, + onProducerStatuses: (statuses) => { producerStatuses = statuses; }, + }); + if (!producerStatuses) throw new Error("career report producer status collection failed"); + const serviceCounts = new Map(); + for (const event of recentEvents) { + const bucket = sourceBucket(event.source); + serviceCounts.set(bucket, (serviceCounts.get(bucket) ?? 0) + 1); + } + const aggregate = counts[0]; + const activity: CareerReportActivity = { + windowDays: 14, + totalEvents: aggregate?.total ?? 0, + completedEvents: aggregate?.completed ?? 0, + openedEvents: aggregate?.opened ?? 0, + services: Array.from(serviceCounts.entries()).map(([service, count]) => ({ service, count })), + opportunityEvents: recentEvents.filter((event) => sourceBucket(event.source) === "opportunities").length, + }; + + return c.json(buildCareerReportProjection({ + profile, + qscore: qscoreRead, + qscoreHistory, + snapshots, + producerStatuses, + activity, + })); + }); + app.get("/activity-history", async (c) => { const userId = c.get("userId"); const limit = Math.min(200, Math.max(1, Number(c.req.query("limit") ?? 80))); diff --git a/src/v1/analytics/career-report.ts b/src/v1/analytics/career-report.ts new file mode 100644 index 0000000..849f29e --- /dev/null +++ b/src/v1/analytics/career-report.ts @@ -0,0 +1,358 @@ +import type { + HomeInsightProducerStatuses, + HomeInsightSnapshots, + InsightSnapshotStatus, +} from "./home-insight-snapshot.js"; +import type { + QscoreHistoryPoint, + QscoreProxyResult, + QscoreReadResult, +} from "../../services/qscore-proxy.js"; + +export type CareerReportStatus = InsightSnapshotStatus; + +export type CareerReportField = { + status: CareerReportStatus; + source: string; + value: T | null; + updatedAt: string | null; + reason: string | null; +}; + +export type CareerReportSignal = { + id: string; + label: string; + present: boolean; + score: number; +}; + +export type CareerReportCategory = { + id: string; + label: string; + inputIds: string[]; + weightPercent: number; + score: number; + active: boolean; + presentRules: number; + totalRules: number; + signals: CareerReportSignal[]; +}; + +export type CareerReportDisplay = { + version: "rq-score-display-v1"; + registeredSignalCount: number; + activeSignalCount: number; + categories: CareerReportCategory[]; +}; + +export type CareerReportActivity = { + windowDays: 14; + totalEvents: number; + completedEvents: number; + openedEvents: number; + services: Array<{ service: string; count: number }>; + opportunityEvents: number; +}; + +export type CareerReportSourceId = + | "profile" + | "onboarding" + | "qscore" + | "resume" + | "linkedin" + | "interview" + | "roleplay" + | "matchmaking" + | "courses" + | "assessment" + | "grow_events"; + +export type CareerReportSource = { + id: CareerReportSourceId; + label: string; + status: CareerReportStatus; + updatedAt: string | null; + reason: string | null; +}; + +export type CareerReportProfileRead = + | { status: "ready"; data: Record } + | { status: "unavailable"; reason: "profile_unavailable" | "profile_response_invalid" }; + +export type CareerReportResponse = { + contractVersion: "career-report-v1"; + generatedAt: string; + subject: { + displayName: CareerReportField; + avatarUrl: CareerReportField; + targetRole: CareerReportField; + targetField: CareerReportField; + experienceLevel: CareerReportField; + currentSituation: CareerReportField; + goals: CareerReportField; + barriers: CareerReportField; + }; + rqScore: { + score: CareerReportField; + history: CareerReportField>; + display: CareerReportField; + }; + snapshots: HomeInsightSnapshots; + activity: CareerReportField; + sources: CareerReportSource[]; +}; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function finiteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function nonNegativeInteger(value: unknown): number | null { + const number = finiteNumber(value); + return number !== null && Number.isInteger(number) && number >= 0 ? number : null; +} + +function stringValue(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function stringArray(value: unknown): string[] | null { + if (!Array.isArray(value)) return null; + const items = value.flatMap((item) => { + const parsed = stringValue(item); + return parsed ? [parsed] : []; + }); + return items.length === value.length ? items : null; +} + +function readyField(source: string, value: T, updatedAt: string | null = null): CareerReportField { + return { status: "ready", source, value, updatedAt, reason: null }; +} + +function emptyField(source: string, value: T | null, reason: string): CareerReportField { + return { status: "empty", source, value, updatedAt: null, reason }; +} + +function unavailableField(source: string, reason: string): CareerReportField { + return { status: "unavailable", source, value: null, updatedAt: null, reason }; +} + +function unsupportedField(source: string, reason: string, updatedAt: string | null = null): CareerReportField { + return { status: "unsupported", source, value: null, updatedAt, reason }; +} + +function scalarField(source: string, value: unknown, updatedAt: string | null = null): CareerReportField { + const parsed = stringValue(value); + return parsed ? readyField(source, parsed, updatedAt) : { ...emptyField(source, null, "field_not_present"), updatedAt }; +} + +function arrayField(source: string, value: unknown, updatedAt: string | null = null): CareerReportField { + const parsed = stringArray(value); + if (parsed === null) return { ...emptyField(source, [], "field_not_present"), updatedAt }; + return parsed.length ? readyField(source, parsed, updatedAt) : { ...emptyField(source, [], "no_values"), updatedAt }; +} + +function displayName(profile: Record): string | null { + const parts = [stringValue(profile.first_name ?? profile.firstName), stringValue(profile.last_name ?? profile.lastName)].filter( + (value): value is string => !!value, + ); + return parts.length ? parts.join(" ") : null; +} + +function avatarUrl(profile: Record): string | null { + return stringValue( + profile.avatar_url ?? + profile.avatarUrl ?? + profile.image_url ?? + profile.imageUrl ?? + profile.profile_image_url ?? + profile.profileImageUrl, + ); +} + +function parseSignal(value: unknown): CareerReportSignal | null { + if (!isRecord(value)) return null; + const id = stringValue(value.id); + const label = stringValue(value.label); + const score = finiteNumber(value.score); + if (!id || !label || score === null || typeof value.present !== "boolean") return null; + return { id, label, present: value.present, score }; +} + +function parseCategory(value: unknown): CareerReportCategory | null { + if (!isRecord(value)) return null; + const id = stringValue(value.id); + const label = stringValue(value.label); + const inputIds = stringArray(value.input_ids); + const weightPercent = finiteNumber(value.weight_percent); + const score = finiteNumber(value.score); + const presentRules = nonNegativeInteger(value.present_rules); + const totalRules = nonNegativeInteger(value.total_rules); + if ( + !id || + !label || + inputIds === null || + weightPercent === null || + score === null || + presentRules === null || + totalRules === null || + typeof value.active !== "boolean" || + !Array.isArray(value.signals) + ) return null; + const signals = value.signals.map(parseSignal); + if (signals.some((signal) => signal === null)) return null; + const parsedSignals = signals as CareerReportSignal[]; + if ( + parsedSignals.length !== totalRules || + parsedSignals.filter((signal) => signal.present).length !== presentRules || + value.active !== (presentRules > 0) || + new Set(parsedSignals.map((signal) => signal.id)).size !== parsedSignals.length + ) return null; + return { id, label, inputIds, weightPercent, score, active: value.active, presentRules, totalRules, signals: parsedSignals }; +} + +/** Strictly validates the producer-owned canonical display contract without recalculating it. */ +export function parseCareerReportDisplay(value: unknown): CareerReportDisplay | null { + if (!isRecord(value) || value.version !== "rq-score-display-v1" || !Array.isArray(value.categories)) return null; + const registeredSignalCount = nonNegativeInteger(value.registered_signal_count); + const activeSignalCount = nonNegativeInteger(value.active_signal_count); + const categories = value.categories.map(parseCategory); + if (registeredSignalCount === null || activeSignalCount === null || categories.some((category) => category === null)) return null; + const parsedCategories = categories as CareerReportCategory[]; + const signals = parsedCategories.flatMap((category) => category.signals); + if ( + parsedCategories.length !== 9 || + registeredSignalCount !== 67 || + signals.length !== registeredSignalCount || + signals.filter((signal) => signal.present).length !== activeSignalCount || + new Set(parsedCategories.map((category) => category.id)).size !== parsedCategories.length || + new Set(signals.map((signal) => signal.id)).size !== signals.length + ) return null; + return { version: "rq-score-display-v1", registeredSignalCount, activeSignalCount, categories: parsedCategories }; +} + +function sourceFromProducer( + id: CareerReportSourceId, + label: string, + producer: HomeInsightProducerStatuses[keyof HomeInsightProducerStatuses], + updatedAt: string | null, +): CareerReportSource { + return { id, label, status: producer.status, updatedAt, reason: producer.reason }; +} + +function sourceStatus(fields: Array>): CareerReportStatus { + if (fields.some((field) => field.status === "ready")) return "ready"; + if (fields.some((field) => field.status === "unavailable")) return "unavailable"; + if (fields.some((field) => field.status === "unsupported")) return "unsupported"; + return "empty"; +} + +function sourceReason(fields: Array>): string | null { + const status = sourceStatus(fields); + return status === "ready" ? null : fields.find((field) => field.status === status)?.reason ?? null; +} + +export function buildCareerReportProjection(input: { + generatedAt?: string; + profile: CareerReportProfileRead; + qscore: QscoreReadResult; + qscoreHistory: QscoreReadResult; + snapshots: HomeInsightSnapshots; + producerStatuses: HomeInsightProducerStatuses; + activity: CareerReportActivity; +}): CareerReportResponse { + const generatedAt = input.generatedAt ?? new Date().toISOString(); + const profileSource = "user-service.profile"; + const onboardingSource = "user-service.preferences.onboarding"; + + let displayNameField: CareerReportField; + let avatarUrlField: CareerReportField; + let targetRole: CareerReportField; + let targetField: CareerReportField; + let experienceLevel: CareerReportField; + let currentSituation: CareerReportField; + let goals: CareerReportField; + let barriers: CareerReportField; + + if (input.profile.status === "unavailable") { + displayNameField = unavailableField(profileSource, input.profile.reason); + avatarUrlField = unavailableField(profileSource, input.profile.reason); + targetRole = unavailableField(onboardingSource, input.profile.reason); + targetField = unavailableField(onboardingSource, input.profile.reason); + experienceLevel = unavailableField(onboardingSource, input.profile.reason); + currentSituation = unavailableField(onboardingSource, input.profile.reason); + goals = unavailableField(onboardingSource, input.profile.reason); + barriers = unavailableField(onboardingSource, input.profile.reason); + } else { + const profileUpdatedAt = stringValue(input.profile.data.updated_at ?? input.profile.data.updatedAt); + displayNameField = scalarField(profileSource, displayName(input.profile.data), profileUpdatedAt); + avatarUrlField = scalarField(profileSource, avatarUrl(input.profile.data), profileUpdatedAt); + const preferences = isRecord(input.profile.data.preferences) ? input.profile.data.preferences : {}; + const onboarding = isRecord(preferences.onboarding) ? preferences.onboarding : {}; + const responses = isRecord(onboarding.responses) ? onboarding.responses : {}; + const progress = isRecord(onboarding.progress) ? onboarding.progress : {}; + const onboardingUpdatedAt = stringValue(progress.updated_at ?? onboarding.completed_at); + targetRole = scalarField(onboardingSource, responses.target_role, onboardingUpdatedAt); + targetField = scalarField(onboardingSource, responses.target_field, onboardingUpdatedAt); + experienceLevel = scalarField(onboardingSource, responses.experience_level, onboardingUpdatedAt); + currentSituation = scalarField(onboardingSource, responses.current_situation, onboardingUpdatedAt); + goals = arrayField(onboardingSource, responses.desired_outcomes, onboardingUpdatedAt); + barriers = arrayField(onboardingSource, responses.career_barriers, onboardingUpdatedAt); + } + + let qscoreScore: CareerReportField; + let qscoreDisplay: CareerReportField; + if (input.qscore.status === "ready") { + qscoreScore = readyField("qscore.current", input.qscore.data.rq_score, input.qscore.data.calculated_at || null); + const parsedDisplay = parseCareerReportDisplay(input.qscore.data.breakdown.display); + qscoreDisplay = parsedDisplay + ? readyField("qscore.breakdown.display", parsedDisplay, input.qscore.data.calculated_at || null) + : unsupportedField("qscore.breakdown.display", "display_contract_missing", input.qscore.data.calculated_at || null); + } else if (input.qscore.status === "empty") { + qscoreScore = emptyField("qscore.current", null, "no_qscore_run"); + qscoreDisplay = emptyField("qscore.breakdown.display", null, "no_qscore_run"); + } else { + qscoreScore = unavailableField("qscore.current", input.qscore.reason); + qscoreDisplay = unavailableField("qscore.breakdown.display", input.qscore.reason); + } + + let history: CareerReportField>; + if (input.qscoreHistory.status === "ready") { + const points = input.qscoreHistory.data.map((point) => ({ date: point.date, score: point.rq_score })); + history = points.length ? readyField("qscore.history", points) : emptyField("qscore.history", [], "no_history_points"); + } else if (input.qscoreHistory.status === "empty") { + history = emptyField("qscore.history", [], "no_qscore_history"); + } else { + history = unavailableField("qscore.history", input.qscoreHistory.reason); + } + + const profileFields: Array> = [displayNameField, avatarUrlField]; + const onboardingFields: Array> = [targetRole, targetField, experienceLevel, currentSituation, goals, barriers]; + const activity = readyField("grow_events", input.activity, generatedAt); + const sources: CareerReportSource[] = [ + { id: "profile", label: "Profile", status: sourceStatus(profileFields), updatedAt: displayNameField.updatedAt ?? avatarUrlField.updatedAt, reason: sourceReason(profileFields) }, + { id: "onboarding", label: "Onboarding", status: sourceStatus(onboardingFields), updatedAt: targetRole.updatedAt, reason: sourceReason(onboardingFields) }, + { id: "qscore", label: "RQ Score", status: input.qscore.status === "ready" ? "ready" : input.qscore.status, updatedAt: qscoreScore.updatedAt, reason: qscoreScore.reason }, + sourceFromProducer("resume", "Resume", input.producerStatuses.resume, input.snapshots.resume.source === "resume.state" ? input.snapshots.resume.updatedAt : null), + sourceFromProducer("linkedin", "LinkedIn", input.producerStatuses.linkedin, input.snapshots.socialBrand.source === "social.state" ? input.snapshots.socialBrand.updatedAt : null), + sourceFromProducer("interview", "Interview", input.producerStatuses.interview, input.snapshots.interview.source === "interview.page-state" ? input.snapshots.interview.updatedAt : null), + sourceFromProducer("roleplay", "Roleplay", input.producerStatuses.roleplay, input.snapshots.roleplay.source === "roleplay.page-state" ? input.snapshots.roleplay.updatedAt : null), + sourceFromProducer("matchmaking", "Matchmaking", input.producerStatuses.matchmaking, input.snapshots.opportunity.source === "matchmaking.scout-stats" ? input.snapshots.opportunity.updatedAt : null), + sourceFromProducer("courses", "Courses", input.producerStatuses.courses, input.snapshots.learning.source === "courses.course-state" ? input.snapshots.learning.updatedAt : null), + sourceFromProducer("assessment", "Assessment", input.producerStatuses.assessment, input.snapshots.skillGap.source === "assessment.list" ? input.snapshots.skillGap.updatedAt : null), + { id: "grow_events", label: "Grow events", status: "ready", updatedAt: generatedAt, reason: null }, + ]; + + return { + contractVersion: "career-report-v1", + generatedAt, + subject: { displayName: displayNameField, avatarUrl: avatarUrlField, targetRole, targetField, experienceLevel, currentSituation, goals, barriers }, + rqScore: { score: qscoreScore, history, display: qscoreDisplay }, + snapshots: input.snapshots, + activity, + sources, + }; +} diff --git a/src/v1/analytics/home-insight-snapshot.ts b/src/v1/analytics/home-insight-snapshot.ts index 0b3c91f..b0ae809 100644 --- a/src/v1/analytics/home-insight-snapshot.ts +++ b/src/v1/analytics/home-insight-snapshot.ts @@ -68,6 +68,21 @@ type ProducerRead = | { status: "ready"; data: Record } | { status: "unavailable"; reason: "producer_unavailable" | "producer_response_invalid" }; +export type HomeInsightProducerStatus = { + status: "ready" | "unavailable"; + reason: "producer_unavailable" | "producer_response_invalid" | null; +}; + +export type HomeInsightProducerStatuses = { + interview: HomeInsightProducerStatus; + roleplay: HomeInsightProducerStatus; + resume: HomeInsightProducerStatus; + linkedin: HomeInsightProducerStatus; + matchmaking: HomeInsightProducerStatus; + courses: HomeInsightProducerStatus; + assessment: HomeInsightProducerStatus; +}; + type CategoryEvidence = { id: string; label: string; @@ -258,6 +273,12 @@ function a2aData(read: ProducerRead, action: string): ProducerRead { : { status: "unavailable", reason: "producer_response_invalid" }; } +function producerStatus(read: ProducerRead): HomeInsightProducerStatus { + return read.status === "ready" + ? { status: "ready", reason: null } + : { status: "unavailable", reason: read.reason }; +} + function latestSessionScore(data: Record): { score: number | null; count: number; updatedAt: string | null } { const sessions = Array.isArray(data.recent_sessions) ? data.recent_sessions.filter(isRecord) : []; const latest = sessions[0]; @@ -293,6 +314,7 @@ export async function buildHomeInsightSnapshots(input: { qscore: QscoreReadResult; qscoreHistory: QscoreReadResult; recentEvents: InsightEvent[]; + onProducerStatuses?: (statuses: HomeInsightProducerStatuses) => void; }, dependencies: ProducerDependencies = defaultDependencies): Promise { const [interviewRaw, roleplayRaw, resumeRead, socialRaw, matchmakingRaw, courseRaw, assessmentRead] = await Promise.all([ readProducer(() => dependencies.interviewPageState(input.userId)), @@ -306,6 +328,17 @@ export async function buildHomeInsightSnapshots(input: { const interviewRead = sessionPageData(interviewRaw); const roleplayRead = sessionPageData(roleplayRaw); const socialRead = socialStateData(socialRaw); + const matchmakingRead = a2aData(matchmakingRaw, "scout_stats"); + const courseRead = a2aData(courseRaw, "course_state_loaded"); + input.onProducerStatuses?.({ + interview: producerStatus(interviewRead), + roleplay: producerStatus(roleplayRead), + resume: producerStatus(resumeRead), + linkedin: producerStatus(socialRead), + matchmaking: producerStatus(matchmakingRead), + courses: producerStatus(courseRead), + assessment: producerStatus(assessmentRead), + }); const qscoreData = input.qscore.status === "ready" ? input.qscore.data : null; const historyDetail = input.qscoreHistory.status === "ready" @@ -376,7 +409,6 @@ export async function buildHomeInsightSnapshots(input: { } const opportunityFallback = qscoreCategorySnapshot(input.qscore, "matching_activity", "Opportunities matched"); - const matchmakingRead = a2aData(matchmakingRaw, "scout_stats"); let opportunity = unavailableWhenNoUsableFallback(matchmakingRead, opportunityFallback, "matchmaking.scout-stats", "Opportunities matched", "matches"); if (matchmakingRead.status === "ready") { const matches = finiteCount(matchmakingRead.data.matchesFound); @@ -469,7 +501,6 @@ export async function buildHomeInsightSnapshots(input: { } const learningFallback = qscoreCategorySnapshot(input.qscore, "learning_credentials", "Learning progress"); - const courseRead = a2aData(courseRaw, "course_state_loaded"); let learning = unavailableWhenNoUsableFallback(courseRead, learningFallback, "courses.course-state", "Learning progress", "videos"); if (courseRead.status === "ready") { const hasProfile = courseRead.data.has_interest_profile === true; -- 2.49.1 From 461b893fdc5dfdedca46436d8868b14d1908638b Mon Sep 17 00:00:00 2001 From: Sai-karthik Date: Wed, 15 Jul 2026 19:37:44 +0000 Subject: [PATCH 2/3] fix: harden career report producer contracts --- scripts/career-report.test.ts | 93 +++++++++++++++++++---- scripts/home-insight-snapshot.test.ts | 18 +++++ src/v1/analytics/README.md | 7 ++ src/v1/analytics/analytics-routes.ts | 18 +++-- src/v1/analytics/career-report.ts | 52 ++++++++++--- src/v1/analytics/home-insight-snapshot.ts | 26 ++++++- 6 files changed, 184 insertions(+), 30 deletions(-) diff --git a/scripts/career-report.test.ts b/scripts/career-report.test.ts index dd82fc3..df6c019 100644 --- a/scripts/career-report.test.ts +++ b/scripts/career-report.test.ts @@ -8,29 +8,29 @@ import { import type { HomeInsightSnapshots, InsightSnapshot } from "../src/v1/analytics/home-insight-snapshot.js"; import type { QscoreProxyResult } from "../src/services/qscore-proxy.js"; -const categoryIds = [ - "learning_credentials", - "resume_presence", - "linkedin_presence", - "matching_activity", - "interview_performance", - "pathway_progress", - "roleplay_performance", - "engagement_consistency", - "goals_readiness", +const categorySpecs = [ + ["learning_credentials", "Learning & Credentials"], + ["resume_presence", "Resume Presence"], + ["linkedin_presence", "LinkedIn Presence"], + ["matching_activity", "Matching Activity"], + ["interview_performance", "Interview Performance"], + ["pathway_progress", "Pathway Progress"], + ["roleplay_performance", "Roleplay Performance"], + ["engagement_consistency", "Engagement & Consistency"], + ["goals_readiness", "Goals & Readiness"], ]; function displayFixture() { let signalIndex = 0; - const categories = categoryIds.map((id, categoryIndex) => { - const count = categoryIndex === categoryIds.length - 1 ? 3 : 8; + const categories = categorySpecs.map(([id, label], categoryIndex) => { + const count = categoryIndex === categorySpecs.length - 1 ? 3 : 8; const signals = Array.from({ length: count }, () => { const index = signalIndex++; return { id: `signal.${index}`, label: `Signal ${index}`, present: index < 4, score: index < 4 ? 50 : 0 }; }); return { id, - label: id, + label, input_ids: [`input_${categoryIndex}`], weight_percent: 10, score: signals.some((signal) => signal.present) ? 50 : 0, @@ -162,6 +162,73 @@ assert.equal(report.sources.find((source) => source.id === "grow_events")?.statu const malformed = structuredClone(displayFixture()); malformed.registered_signal_count = 66; assert.equal(parseCareerReportDisplay(malformed), null, "mislabelled canonical display must be rejected"); +const reportWithMalformedDisplay = buildCareerReportProjection({ + profile: { status: "ready", data: {} }, + qscore: { status: "ready", data: { ...qscore, breakdown: { display: malformed } } }, + qscoreHistory: { status: "ready", data: [] }, + snapshots, + producerStatuses, + activity, +}); +assert.equal(reportWithMalformedDisplay.rqScore.display.status, "unavailable"); +assert.equal(reportWithMalformedDisplay.rqScore.display.value, null); +assert.equal(reportWithMalformedDisplay.rqScore.display.reason, "display_contract_invalid"); + +const wrongCategoryId = structuredClone(displayFixture()); +wrongCategoryId.categories[0].id = "not_a_canonical_category"; +assert.equal(parseCareerReportDisplay(wrongCategoryId), null, "wrong category ids must be rejected"); + +const wrongCategoryOrder = structuredClone(displayFixture()); +[wrongCategoryOrder.categories[0], wrongCategoryOrder.categories[1]] = [wrongCategoryOrder.categories[1], wrongCategoryOrder.categories[0]]; +assert.equal(parseCareerReportDisplay(wrongCategoryOrder), null, "canonical categories out of order must be rejected"); + +const outOfRangeCategoryScore = structuredClone(displayFixture()); +outOfRangeCategoryScore.categories[0].score = 101; +assert.equal(parseCareerReportDisplay(outOfRangeCategoryScore), null, "category scores above 100 must be rejected"); + +const outOfRangeSignalScore = structuredClone(displayFixture()); +outOfRangeSignalScore.categories[0].signals[0].score = -1; +assert.equal(parseCareerReportDisplay(outOfRangeSignalScore), null, "signal scores below zero must be rejected"); + +const outOfRangeWeight = structuredClone(displayFixture()); +outOfRangeWeight.categories[0].weight_percent = 101; +assert.equal(parseCareerReportDisplay(outOfRangeWeight), null, "category weights above 100 must be rejected"); + +const duplicatedSignal = structuredClone(displayFixture()); +duplicatedSignal.categories[1].signals[0].id = duplicatedSignal.categories[0].signals[0].id; +assert.equal(parseCareerReportDisplay(duplicatedSignal), null, "signals assigned more than once must be rejected"); + +const categoryCountMismatch = structuredClone(displayFixture()); +categoryCountMismatch.categories[0].present_rules += 1; +assert.equal(parseCareerReportDisplay(categoryCountMismatch), null, "category count mismatches must be rejected"); + +const malformedOnboarding = buildCareerReportProjection({ + profile: { + status: "ready", + data: { + preferences: { + onboarding: { + responses: { + target_role: 42, + desired_outcomes: ["Real goal", 7], + }, + }, + }, + }, + }, + qscore: { status: "ready", data: qscore }, + qscoreHistory: { status: "ready", data: [] }, + snapshots, + producerStatuses, + activity, +}); +assert.equal(malformedOnboarding.subject.targetRole.status, "unavailable"); +assert.equal(malformedOnboarding.subject.targetRole.value, null); +assert.equal(malformedOnboarding.subject.targetRole.reason, "producer_response_invalid"); +assert.equal(malformedOnboarding.subject.goals.status, "unavailable"); +assert.equal(malformedOnboarding.subject.goals.value, null); +assert.equal(malformedOnboarding.subject.barriers.status, "empty", "missing optional arrays remain confirmed empty"); +assert.deepEqual(malformedOnboarding.subject.barriers.value, []); const unavailable = buildCareerReportProjection({ profile: { status: "unavailable", reason: "profile_unavailable" }, diff --git a/scripts/home-insight-snapshot.test.ts b/scripts/home-insight-snapshot.test.ts index 61bccc7..9407ed9 100644 --- a/scripts/home-insight-snapshot.test.ts +++ b/scripts/home-insight-snapshot.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { buildHomeInsightSnapshots } from "../src/v1/analytics/home-insight-snapshot.js"; +import type { HomeInsightProducerStatuses } from "../src/v1/analytics/home-insight-snapshot.js"; import type { QscoreProxyResult, QscoreReadResult } from "../src/services/qscore-proxy.js"; function qscoreFixture(): QscoreProxyResult { @@ -218,4 +219,21 @@ assert.equal(malformedA2a.opportunity.status, "ready", "valid qscore category is assert.equal(malformedA2a.opportunity.source, "qscore.breakdown.display"); assert.equal(malformedA2a.opportunity.value, 75); +let malformedDirectStatuses: HomeInsightProducerStatuses | null = null; +await buildHomeInsightSnapshots({ + userId: "user_fixture", + qscore: readyQscore, + qscoreHistory: history, + recentEvents: [], + onProducerStatuses: (statuses) => { malformedDirectStatuses = statuses; }, +}, { + ...producers, + resumeState: async () => ({}), + assessments: async () => ({}), +}); +assert.equal(malformedDirectStatuses?.resume.status, "unavailable", "malformed resume objects must not mark the direct source ready"); +assert.equal(malformedDirectStatuses?.resume.reason, "producer_response_invalid"); +assert.equal(malformedDirectStatuses?.assessment.status, "unavailable", "malformed assessment objects must not mark the direct source ready"); +assert.equal(malformedDirectStatuses?.assessment.reason, "producer_response_invalid"); + console.log("home insight snapshot contract: ok"); diff --git a/src/v1/analytics/README.md b/src/v1/analytics/README.md index 6f8d4d8..c2a9d5e 100644 --- a/src/v1/analytics/README.md +++ b/src/v1/analytics/README.md @@ -98,6 +98,13 @@ means a successful read confirmed no value or an empty array/count; expected producer contract is absent. Errors and missing integrations never become numeric zeroes. +Wrongly typed onboarding values and malformed canonical RQ display payloads +are `unavailable`, never `empty` or `ready`. RQ display validation enforces the +exact nine categories in producer order, all 67 uniquely assigned signals, +and bounded scores, weights, and counts. Activity totals and service buckets +are aggregated across the complete 14-day database window; the bounded recent +event sample is used only as supporting snapshot evidence. + The endpoint deliberately does not calculate labels, rankings, strongest or weakest categories, recommendations, timelines, peer/market benchmarks, profile scores, percentages, or narrative prose. It creates no database rows, diff --git a/src/v1/analytics/analytics-routes.ts b/src/v1/analytics/analytics-routes.ts index fb8ada7..3bc66c9 100644 --- a/src/v1/analytics/analytics-routes.ts +++ b/src/v1/analytics/analytics-routes.ts @@ -164,7 +164,7 @@ export function v1AnalyticsRoutes() { ? { status: "ready" as const, data } : { status: "unavailable" as const, reason: "profile_response_invalid" as const }) .catch(() => ({ status: "unavailable" as const, reason: "profile_unavailable" as const })); - const [qscoreRead, qscoreHistory, profile, recentEvents, counts] = await Promise.all([ + const [qscoreRead, qscoreHistory, profile, recentEvents, counts, sourceCounts] = await Promise.all([ readQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID), readQscoreHistoryFromService(userId, DEFAULT_QSCORE_ORG_ID), profilePromise, @@ -182,6 +182,14 @@ export function v1AnalyticsRoutes() { }) .from(growEvents) .where(and(eq(growEvents.userId, userId), gte(growEvents.occurredAt, daysAgo(14)))), + db + .select({ + source: growEvents.source, + count: sql`count(*)::int`, + }) + .from(growEvents) + .where(and(eq(growEvents.userId, userId), gte(growEvents.occurredAt, daysAgo(14)))) + .groupBy(growEvents.source), ]); let producerStatuses: HomeInsightProducerStatuses | null = null; const snapshots = await buildHomeInsightSnapshots({ @@ -193,9 +201,9 @@ export function v1AnalyticsRoutes() { }); if (!producerStatuses) throw new Error("career report producer status collection failed"); const serviceCounts = new Map(); - for (const event of recentEvents) { - const bucket = sourceBucket(event.source); - serviceCounts.set(bucket, (serviceCounts.get(bucket) ?? 0) + 1); + for (const row of sourceCounts) { + const bucket = sourceBucket(row.source); + serviceCounts.set(bucket, (serviceCounts.get(bucket) ?? 0) + row.count); } const aggregate = counts[0]; const activity: CareerReportActivity = { @@ -204,7 +212,7 @@ export function v1AnalyticsRoutes() { completedEvents: aggregate?.completed ?? 0, openedEvents: aggregate?.opened ?? 0, services: Array.from(serviceCounts.entries()).map(([service, count]) => ({ service, count })), - opportunityEvents: recentEvents.filter((event) => sourceBucket(event.source) === "opportunities").length, + opportunityEvents: serviceCounts.get("opportunities") ?? 0, }; return c.json(buildCareerReportProjection({ diff --git a/src/v1/analytics/career-report.ts b/src/v1/analytics/career-report.ts index 849f29e..2a7387b 100644 --- a/src/v1/analytics/career-report.ts +++ b/src/v1/analytics/career-report.ts @@ -102,6 +102,18 @@ export type CareerReportResponse = { sources: CareerReportSource[]; }; +const CANONICAL_CATEGORY_SPECS = [ + ["learning_credentials", "Learning & Credentials"], + ["resume_presence", "Resume Presence"], + ["linkedin_presence", "LinkedIn Presence"], + ["matching_activity", "Matching Activity"], + ["interview_performance", "Interview Performance"], + ["pathway_progress", "Pathway Progress"], + ["roleplay_performance", "Roleplay Performance"], + ["engagement_consistency", "Engagement & Consistency"], + ["goals_readiness", "Goals & Readiness"], +] as const; + function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } @@ -115,6 +127,11 @@ function nonNegativeInteger(value: unknown): number | null { return number !== null && Number.isInteger(number) && number >= 0 ? number : null; } +function boundedNumber(value: unknown, minimum: number, maximum: number): number | null { + const number = finiteNumber(value); + return number !== null && number >= minimum && number <= maximum ? number : null; +} + function stringValue(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } @@ -140,18 +157,22 @@ function unavailableField(source: string, reason: string): CareerReportField< return { status: "unavailable", source, value: null, updatedAt: null, reason }; } -function unsupportedField(source: string, reason: string, updatedAt: string | null = null): CareerReportField { - return { status: "unsupported", source, value: null, updatedAt, reason }; -} - function scalarField(source: string, value: unknown, updatedAt: string | null = null): CareerReportField { + if (value === undefined || value === null || (typeof value === "string" && !value.trim())) { + return { ...emptyField(source, null, "field_not_present"), updatedAt }; + } const parsed = stringValue(value); - return parsed ? readyField(source, parsed, updatedAt) : { ...emptyField(source, null, "field_not_present"), updatedAt }; + return parsed + ? readyField(source, parsed, updatedAt) + : { ...unavailableField(source, "producer_response_invalid"), updatedAt }; } function arrayField(source: string, value: unknown, updatedAt: string | null = null): CareerReportField { + if (value === undefined || value === null) { + return { ...emptyField(source, [], "field_not_present"), updatedAt }; + } const parsed = stringArray(value); - if (parsed === null) return { ...emptyField(source, [], "field_not_present"), updatedAt }; + if (parsed === null) return { ...unavailableField(source, "producer_response_invalid"), updatedAt }; return parsed.length ? readyField(source, parsed, updatedAt) : { ...emptyField(source, [], "no_values"), updatedAt }; } @@ -177,7 +198,7 @@ function parseSignal(value: unknown): CareerReportSignal | null { if (!isRecord(value)) return null; const id = stringValue(value.id); const label = stringValue(value.label); - const score = finiteNumber(value.score); + const score = boundedNumber(value.score, 0, 100); if (!id || !label || score === null || typeof value.present !== "boolean") return null; return { id, label, present: value.present, score }; } @@ -187,8 +208,8 @@ function parseCategory(value: unknown): CareerReportCategory | null { const id = stringValue(value.id); const label = stringValue(value.label); const inputIds = stringArray(value.input_ids); - const weightPercent = finiteNumber(value.weight_percent); - const score = finiteNumber(value.score); + const weightPercent = boundedNumber(value.weight_percent, 0, 100); + const score = boundedNumber(value.score, 0, 100); const presentRules = nonNegativeInteger(value.present_rules); const totalRules = nonNegativeInteger(value.total_rules); if ( @@ -206,6 +227,8 @@ function parseCategory(value: unknown): CareerReportCategory | null { if (signals.some((signal) => signal === null)) return null; const parsedSignals = signals as CareerReportSignal[]; if ( + totalRules > 67 || + presentRules > totalRules || parsedSignals.length !== totalRules || parsedSignals.filter((signal) => signal.present).length !== presentRules || value.active !== (presentRules > 0) || @@ -224,13 +247,20 @@ export function parseCareerReportDisplay(value: unknown): CareerReportDisplay | const parsedCategories = categories as CareerReportCategory[]; const signals = parsedCategories.flatMap((category) => category.signals); if ( - parsedCategories.length !== 9 || + parsedCategories.length !== CANONICAL_CATEGORY_SPECS.length || registeredSignalCount !== 67 || + activeSignalCount > registeredSignalCount || signals.length !== registeredSignalCount || signals.filter((signal) => signal.present).length !== activeSignalCount || new Set(parsedCategories.map((category) => category.id)).size !== parsedCategories.length || new Set(signals.map((signal) => signal.id)).size !== signals.length ) return null; + for (const [index, [expectedId, expectedLabel]] of CANONICAL_CATEGORY_SPECS.entries()) { + const category = parsedCategories[index]; + if (!category || category.id !== expectedId || category.label !== expectedLabel) return null; + } + const inputIds = parsedCategories.flatMap((category) => category.inputIds); + if (new Set(inputIds).size !== inputIds.length) return null; return { version: "rq-score-display-v1", registeredSignalCount, activeSignalCount, categories: parsedCategories }; } @@ -310,7 +340,7 @@ export function buildCareerReportProjection(input: { const parsedDisplay = parseCareerReportDisplay(input.qscore.data.breakdown.display); qscoreDisplay = parsedDisplay ? readyField("qscore.breakdown.display", parsedDisplay, input.qscore.data.calculated_at || null) - : unsupportedField("qscore.breakdown.display", "display_contract_missing", input.qscore.data.calculated_at || null); + : { ...unavailableField("qscore.breakdown.display", "display_contract_invalid"), updatedAt: input.qscore.data.calculated_at || null }; } else if (input.qscore.status === "empty") { qscoreScore = emptyField("qscore.current", null, "no_qscore_run"); qscoreDisplay = emptyField("qscore.breakdown.display", null, "no_qscore_run"); diff --git a/src/v1/analytics/home-insight-snapshot.ts b/src/v1/analytics/home-insight-snapshot.ts index b0ae809..925a1d7 100644 --- a/src/v1/analytics/home-insight-snapshot.ts +++ b/src/v1/analytics/home-insight-snapshot.ts @@ -262,6 +262,28 @@ function socialStateData(read: ProducerRead): ProducerRead { : { status: "unavailable", reason: "producer_response_invalid" }; } +function resumeStateData(read: ProducerRead): ProducerRead { + if (read.status === "unavailable") return read; + const count = finiteCount(read.data.resume_count); + const completeness = read.data.resume_completeness; + const status = read.data.resume_status; + const completenessValid = completeness === undefined || completeness === null || ( + finiteNumber(completeness) !== null && Number(completeness) >= 0 && Number(completeness) <= 100 + ); + const statusValid = status === undefined || status === null || text(status) !== null; + if (count === null || !completenessValid || !statusValid || (count > 0 && finiteNumber(completeness) === null)) { + return { status: "unavailable", reason: "producer_response_invalid" }; + } + return read; +} + +function assessmentStateData(read: ProducerRead): ProducerRead { + if (read.status === "unavailable") return read; + return Array.isArray(read.data.items) && finiteCount(read.data.total) !== null + ? read + : { status: "unavailable", reason: "producer_response_invalid" }; +} + function a2aData(read: ProducerRead, action: string): ProducerRead { if (read.status === "unavailable") return read; if (read.data.status !== "completed" || !Array.isArray(read.data.messages)) { @@ -316,7 +338,7 @@ export async function buildHomeInsightSnapshots(input: { recentEvents: InsightEvent[]; onProducerStatuses?: (statuses: HomeInsightProducerStatuses) => void; }, dependencies: ProducerDependencies = defaultDependencies): Promise { - const [interviewRaw, roleplayRaw, resumeRead, socialRaw, matchmakingRaw, courseRaw, assessmentRead] = await Promise.all([ + const [interviewRaw, roleplayRaw, resumeRaw, socialRaw, matchmakingRaw, courseRaw, assessmentRaw] = await Promise.all([ readProducer(() => dependencies.interviewPageState(input.userId)), readProducer(() => dependencies.roleplayPageState(input.userId)), readProducer(() => dependencies.resumeState(input.userId)), @@ -327,7 +349,9 @@ export async function buildHomeInsightSnapshots(input: { ]); const interviewRead = sessionPageData(interviewRaw); const roleplayRead = sessionPageData(roleplayRaw); + const resumeRead = resumeStateData(resumeRaw); const socialRead = socialStateData(socialRaw); + const assessmentRead = assessmentStateData(assessmentRaw); const matchmakingRead = a2aData(matchmakingRaw, "scout_stats"); const courseRead = a2aData(courseRaw, "course_state_loaded"); input.onProducerStatuses?.({ -- 2.49.1 From fcbb93ca6a3988f56935bfc948b4c4cbefa477eb Mon Sep 17 00:00:00 2001 From: Sai-karthik Date: Wed, 15 Jul 2026 19:40:50 +0000 Subject: [PATCH 3/3] fix: distinguish missing career display contract --- scripts/career-report.test.ts | 12 ++++++++++++ src/v1/analytics/career-report.ts | 17 +++++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/scripts/career-report.test.ts b/scripts/career-report.test.ts index df6c019..e599e75 100644 --- a/scripts/career-report.test.ts +++ b/scripts/career-report.test.ts @@ -174,6 +174,18 @@ assert.equal(reportWithMalformedDisplay.rqScore.display.status, "unavailable"); assert.equal(reportWithMalformedDisplay.rqScore.display.value, null); assert.equal(reportWithMalformedDisplay.rqScore.display.reason, "display_contract_invalid"); +const reportWithoutDisplay = buildCareerReportProjection({ + profile: { status: "ready", data: {} }, + qscore: { status: "ready", data: { ...qscore, breakdown: {} } }, + qscoreHistory: { status: "ready", data: [] }, + snapshots, + producerStatuses, + activity, +}); +assert.equal(reportWithoutDisplay.rqScore.display.status, "unsupported"); +assert.equal(reportWithoutDisplay.rqScore.display.value, null); +assert.equal(reportWithoutDisplay.rqScore.display.reason, "display_contract_missing"); + const wrongCategoryId = structuredClone(displayFixture()); wrongCategoryId.categories[0].id = "not_a_canonical_category"; assert.equal(parseCareerReportDisplay(wrongCategoryId), null, "wrong category ids must be rejected"); diff --git a/src/v1/analytics/career-report.ts b/src/v1/analytics/career-report.ts index 2a7387b..05dc50d 100644 --- a/src/v1/analytics/career-report.ts +++ b/src/v1/analytics/career-report.ts @@ -157,6 +157,10 @@ function unavailableField(source: string, reason: string): CareerReportField< return { status: "unavailable", source, value: null, updatedAt: null, reason }; } +function unsupportedField(source: string, reason: string, updatedAt: string | null = null): CareerReportField { + return { status: "unsupported", source, value: null, updatedAt, reason }; +} + function scalarField(source: string, value: unknown, updatedAt: string | null = null): CareerReportField { if (value === undefined || value === null || (typeof value === "string" && !value.trim())) { return { ...emptyField(source, null, "field_not_present"), updatedAt }; @@ -337,10 +341,15 @@ export function buildCareerReportProjection(input: { let qscoreDisplay: CareerReportField; if (input.qscore.status === "ready") { qscoreScore = readyField("qscore.current", input.qscore.data.rq_score, input.qscore.data.calculated_at || null); - const parsedDisplay = parseCareerReportDisplay(input.qscore.data.breakdown.display); - qscoreDisplay = parsedDisplay - ? readyField("qscore.breakdown.display", parsedDisplay, input.qscore.data.calculated_at || null) - : { ...unavailableField("qscore.breakdown.display", "display_contract_invalid"), updatedAt: input.qscore.data.calculated_at || null }; + const rawDisplay = input.qscore.data.breakdown.display; + if (rawDisplay === undefined || rawDisplay === null) { + qscoreDisplay = unsupportedField("qscore.breakdown.display", "display_contract_missing", input.qscore.data.calculated_at || null); + } else { + const parsedDisplay = parseCareerReportDisplay(rawDisplay); + qscoreDisplay = parsedDisplay + ? readyField("qscore.breakdown.display", parsedDisplay, input.qscore.data.calculated_at || null) + : { ...unavailableField("qscore.breakdown.display", "display_contract_invalid"), updatedAt: input.qscore.data.calculated_at || null }; + } } else if (input.qscore.status === "empty") { qscoreScore = emptyField("qscore.current", null, "no_qscore_run"); qscoreDisplay = emptyField("qscore.breakdown.display", null, "no_qscore_run"); -- 2.49.1