feat: expose live Career Report data contract #20
@@ -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",
|
||||
|
||||
264
scripts/career-report.test.ts
Normal file
264
scripts/career-report.test.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
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 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 = 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,
|
||||
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> = {}): 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 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 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");
|
||||
|
||||
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" },
|
||||
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");
|
||||
@@ -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");
|
||||
|
||||
@@ -78,3 +78,41 @@ 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.
|
||||
|
||||
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,
|
||||
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.
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
@@ -148,6 +157,74 @@ export function v1AnalyticsRoutes() {
|
||||
return c.json(response);
|
||||
});
|
||||
|
||||
app.get("/career-report", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const profilePromise: Promise<CareerReportProfileRead> = 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, sourceCounts] = 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<number>`count(*)::int`,
|
||||
completed: sql<number>`count(*) filter (where ${growEvents.type} ilike '%completed%' or ${growEvents.type} ilike '%review_completed%')::int`,
|
||||
opened: sql<number>`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)))),
|
||||
db
|
||||
.select({
|
||||
source: growEvents.source,
|
||||
count: sql<number>`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({
|
||||
userId,
|
||||
qscore: qscoreRead,
|
||||
qscoreHistory,
|
||||
recentEvents,
|
||||
onProducerStatuses: (statuses) => { producerStatuses = statuses; },
|
||||
});
|
||||
if (!producerStatuses) throw new Error("career report producer status collection failed");
|
||||
const serviceCounts = new Map<string, number>();
|
||||
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 = {
|
||||
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: serviceCounts.get("opportunities") ?? 0,
|
||||
};
|
||||
|
||||
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)));
|
||||
|
||||
397
src/v1/analytics/career-report.ts
Normal file
397
src/v1/analytics/career-report.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
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<T> = {
|
||||
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<string, unknown> }
|
||||
| { status: "unavailable"; reason: "profile_unavailable" | "profile_response_invalid" };
|
||||
|
||||
export type CareerReportResponse = {
|
||||
contractVersion: "career-report-v1";
|
||||
generatedAt: string;
|
||||
subject: {
|
||||
displayName: CareerReportField<string>;
|
||||
avatarUrl: CareerReportField<string>;
|
||||
targetRole: CareerReportField<string>;
|
||||
targetField: CareerReportField<string>;
|
||||
experienceLevel: CareerReportField<string>;
|
||||
currentSituation: CareerReportField<string>;
|
||||
goals: CareerReportField<string[]>;
|
||||
barriers: CareerReportField<string[]>;
|
||||
};
|
||||
rqScore: {
|
||||
score: CareerReportField<number>;
|
||||
history: CareerReportField<Array<{ date: string; score: number }>>;
|
||||
display: CareerReportField<CareerReportDisplay>;
|
||||
};
|
||||
snapshots: HomeInsightSnapshots;
|
||||
activity: CareerReportField<CareerReportActivity>;
|
||||
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<string, unknown> {
|
||||
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 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;
|
||||
}
|
||||
|
||||
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<T>(source: string, value: T, updatedAt: string | null = null): CareerReportField<T> {
|
||||
return { status: "ready", source, value, updatedAt, reason: null };
|
||||
}
|
||||
|
||||
function emptyField<T>(source: string, value: T | null, reason: string): CareerReportField<T> {
|
||||
return { status: "empty", source, value, updatedAt: null, reason };
|
||||
}
|
||||
|
||||
function unavailableField<T>(source: string, reason: string): CareerReportField<T> {
|
||||
return { status: "unavailable", source, value: null, updatedAt: null, reason };
|
||||
}
|
||||
|
||||
function unsupportedField<T>(source: string, reason: string, updatedAt: string | null = null): CareerReportField<T> {
|
||||
return { status: "unsupported", source, value: null, updatedAt, reason };
|
||||
}
|
||||
|
||||
function scalarField(source: string, value: unknown, updatedAt: string | null = null): CareerReportField<string> {
|
||||
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)
|
||||
: { ...unavailableField<string>(source, "producer_response_invalid"), updatedAt };
|
||||
}
|
||||
|
||||
function arrayField(source: string, value: unknown, updatedAt: string | null = null): CareerReportField<string[]> {
|
||||
if (value === undefined || value === null) {
|
||||
return { ...emptyField(source, [], "field_not_present"), updatedAt };
|
||||
}
|
||||
const parsed = stringArray(value);
|
||||
if (parsed === null) return { ...unavailableField<string[]>(source, "producer_response_invalid"), updatedAt };
|
||||
return parsed.length ? readyField(source, parsed, updatedAt) : { ...emptyField(source, [], "no_values"), updatedAt };
|
||||
}
|
||||
|
||||
function displayName(profile: Record<string, unknown>): 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, unknown>): 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 = boundedNumber(value.score, 0, 100);
|
||||
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 = 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 (
|
||||
!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 (
|
||||
totalRules > 67 ||
|
||||
presentRules > totalRules ||
|
||||
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 !== 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 };
|
||||
}
|
||||
|
||||
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<CareerReportField<unknown>>): 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<CareerReportField<unknown>>): 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<QscoreProxyResult>;
|
||||
qscoreHistory: QscoreReadResult<QscoreHistoryPoint[]>;
|
||||
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<string>;
|
||||
let avatarUrlField: CareerReportField<string>;
|
||||
let targetRole: CareerReportField<string>;
|
||||
let targetField: CareerReportField<string>;
|
||||
let experienceLevel: CareerReportField<string>;
|
||||
let currentSituation: CareerReportField<string>;
|
||||
let goals: CareerReportField<string[]>;
|
||||
let barriers: CareerReportField<string[]>;
|
||||
|
||||
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<number>;
|
||||
let qscoreDisplay: CareerReportField<CareerReportDisplay>;
|
||||
if (input.qscore.status === "ready") {
|
||||
qscoreScore = readyField("qscore.current", input.qscore.data.rq_score, 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<CareerReportDisplay>("qscore.breakdown.display", "display_contract_invalid"), updatedAt: input.qscore.data.calculated_at || null };
|
||||
}
|
||||
} else if (input.qscore.status === "empty") {
|
||||
qscoreScore = emptyField<number>("qscore.current", null, "no_qscore_run");
|
||||
qscoreDisplay = emptyField<CareerReportDisplay>("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<Array<{ date: string; score: number }>>;
|
||||
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<CareerReportField<unknown>> = [displayNameField, avatarUrlField];
|
||||
const onboardingFields: Array<CareerReportField<unknown>> = [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,
|
||||
};
|
||||
}
|
||||
@@ -68,6 +68,21 @@ type ProducerRead =
|
||||
| { status: "ready"; data: Record<string, unknown> }
|
||||
| { 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;
|
||||
@@ -247,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)) {
|
||||
@@ -258,6 +295,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<string, unknown>): { 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,8 +336,9 @@ export async function buildHomeInsightSnapshots(input: {
|
||||
qscore: QscoreReadResult<QscoreProxyResult>;
|
||||
qscoreHistory: QscoreReadResult<QscoreHistoryPoint[]>;
|
||||
recentEvents: InsightEvent[];
|
||||
onProducerStatuses?: (statuses: HomeInsightProducerStatuses) => void;
|
||||
}, dependencies: ProducerDependencies = defaultDependencies): Promise<HomeInsightSnapshots> {
|
||||
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)),
|
||||
@@ -305,7 +349,20 @@ 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?.({
|
||||
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 +433,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 +525,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;
|
||||
|
||||
Reference in New Issue
Block a user