Canonicalize mission links and preserve mission context in the service gateway #5
@@ -17,6 +17,7 @@ import {
|
||||
import { interviewService, resumeService, roleplayService } from "../services/product-service-clients.js";
|
||||
import { ensureOnboardingBaselineQscore } from "../events/onboarding-qscore.js";
|
||||
import { refineHomeNotificationsWithAgent } from "./home-feed-agent.js";
|
||||
import { missionDetailHref } from "../missions/reducer-helpers.js";
|
||||
import {
|
||||
isAllowedNotificationHref,
|
||||
MODULE_IDS,
|
||||
@@ -167,8 +168,8 @@ function buildDayOneSeeds(): SeedNotification[] {
|
||||
const seeds: SeedNotification[] = [];
|
||||
pushSeed(seeds, { moduleId: "suggestions", title: "Start with your Q Score", subtitle: "A quick readiness scan calibrates resume, interview, and roleplay tips.", tag: "Start", urgency: "now", href: SERVICE_HREFS.qscore, source: "qscore", priority: 90 });
|
||||
pushSeed(seeds, { moduleId: "suggestions", title: "Add your target role", subtitle: "One role goal makes every recommendation sharper.", tag: "Profile", urgency: "today", href: SERVICE_HREFS.suggestions, source: "system", priority: 80 });
|
||||
pushSeed(seeds, { moduleId: "missions", title: "Explore Interview-to-Offer", subtitle: "A guided mission connects resume fit, mock practice, and readiness scoring.", tag: "Browse", urgency: "today", href: SERVICE_HREFS.mission, source: "mission", priority: 80 });
|
||||
pushSeed(seeds, { moduleId: "missions", title: "No approvals pending yet", subtitle: "Start a mission and this tile will track missing steps and progress.", tag: "Quiet", urgency: "calm", href: SERVICE_HREFS.mission, source: "mission", priority: 55 });
|
||||
pushSeed(seeds, { moduleId: "missions", title: "Explore Interview-to-Offer", subtitle: "A guided mission connects resume fit, mock practice, and readiness scoring.", tag: "Browse", urgency: "today", href: "/missions/available", source: "mission", priority: 80 });
|
||||
pushSeed(seeds, { moduleId: "missions", title: "No approvals pending yet", subtitle: "Start a mission and this tile will track missing steps and progress.", tag: "Quiet", urgency: "calm", href: "/missions/available", source: "mission", priority: 55 });
|
||||
pushSeed(seeds, { moduleId: "social", title: "Connect LinkedIn when ready", subtitle: "Social branding recommendations unlock after your profile is available.", tag: "Setup", urgency: "soon", href: SERVICE_HREFS.social, source: "social", priority: 60 });
|
||||
pushSeed(seeds, { moduleId: "social", title: "Build proof before posting", subtitle: "Resume and mock interview artifacts can become stronger featured pins.", tag: "Proof", urgency: "calm", href: SERVICE_HREFS.social, source: "social", priority: 50 });
|
||||
pushSeed(seeds, { moduleId: "pathways", title: "Pathways are warming up", subtitle: "Complete resume + interview activity to unlock better route recommendations.", tag: "Soon", urgency: "calm", href: SERVICE_HREFS.pathways, source: "pathways", priority: 40 });
|
||||
@@ -196,7 +197,10 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
|
||||
for (const suggestion of ctx.missionSuggestions.slice(0, 5)) {
|
||||
const mission = ctx.activeMissions.find((item) => item.instanceId === suggestion.missionInstanceId);
|
||||
const source = sourceFromSuggestionRole(suggestion.role);
|
||||
const href = sanitizeHref(suggestion.ctaHref, mission ? `/missions/active?missionInstanceId=${encodeURIComponent(mission.instanceId)}` : SERVICE_HREFS.mission);
|
||||
const href = sanitizeHref(
|
||||
suggestion.ctaHref,
|
||||
mission ? missionDetailHref(mission.instanceId) : SERVICE_HREFS.mission,
|
||||
);
|
||||
pushSeed(seeds, {
|
||||
moduleId: "suggestions",
|
||||
title: suggestion.title,
|
||||
@@ -267,7 +271,7 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
|
||||
subtitle: mission.currentStageId ? `Current stage: ${mission.currentStageId.replaceAll("-", " ")}` : "Next action is ready on the mission dashboard.",
|
||||
tag: mission.status === "paused" ? "Paused" : "Active",
|
||||
urgency: mission.status === "paused" ? "soon" : "today",
|
||||
href: `/missions/active?missionInstanceId=${encodeURIComponent(mission.instanceId)}`,
|
||||
href: missionDetailHref(mission.instanceId),
|
||||
source: "mission",
|
||||
priority: 90 - mission.progressPercent,
|
||||
});
|
||||
|
||||
@@ -48,7 +48,14 @@ export const MODULE_META: Record<HomeModuleId, Omit<HomeModule, "count" | "notif
|
||||
rewards: { id: "rewards", label: "Rewards", href: "/rewards", accent: "amber" },
|
||||
};
|
||||
|
||||
export const MODULE_IDS: HomeModuleId[] = ["suggestions", "missions", "productivity"];
|
||||
export const MODULE_IDS: HomeModuleId[] = [
|
||||
"suggestions",
|
||||
"missions",
|
||||
"social",
|
||||
"pathways",
|
||||
"productivity",
|
||||
"rewards",
|
||||
];
|
||||
|
||||
export const ALLOWED_NOTIFICATION_HREFS = new Set([
|
||||
"/suggestions",
|
||||
@@ -68,6 +75,7 @@ export const ALLOWED_NOTIFICATION_HREFS = new Set([
|
||||
]);
|
||||
|
||||
export const ALLOWED_NOTIFICATION_HREF_PREFIXES = [
|
||||
"/missions/",
|
||||
"/missions/active",
|
||||
"/missions/available",
|
||||
"/agents/resume",
|
||||
@@ -80,5 +88,9 @@ export const ALLOWED_NOTIFICATION_HREF_PREFIXES = [
|
||||
|
||||
export function isAllowedNotificationHref(href: string) {
|
||||
if (ALLOWED_NOTIFICATION_HREFS.has(href)) return true;
|
||||
return ALLOWED_NOTIFICATION_HREF_PREFIXES.some((prefix) => href === prefix || href.startsWith(`${prefix}?`));
|
||||
return ALLOWED_NOTIFICATION_HREF_PREFIXES.some((prefix) =>
|
||||
prefix.endsWith("/")
|
||||
? href.startsWith(prefix)
|
||||
: href === prefix || href.startsWith(`${prefix}?`),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { missionActions, missionSuggestions } from "../db/schema.js";
|
||||
import type { GrowActiveMission } from "../actors/missions/types.js";
|
||||
import type { MissionActionPatch } from "./reducer-types.js";
|
||||
import { defaultMissionActionStatus, type MissionActionDto, type MissionActionRow, type MissionActionStatus, type NewMissionActionInput } from "./action-types.js";
|
||||
import { missionDetailHref, serviceHref } from "./reducer-helpers.js";
|
||||
|
||||
const OPEN_STATUSES: MissionActionStatus[] = ["queued", "running", "waiting_approval", "waiting_user_input", "failed"];
|
||||
const DONE_STATUSES: MissionActionStatus[] = ["done", "dismissed", "snoozed"];
|
||||
@@ -48,11 +49,11 @@ function ctaForAction(action: MissionActionRow | NewMissionActionInput) {
|
||||
const payload = action.payload && typeof action.payload === "object" && !Array.isArray(action.payload) ? action.payload as Record<string, unknown> : {};
|
||||
const hrefFromPayload = typeof payload.href === "string" ? payload.href : undefined;
|
||||
const serviceId = action.serviceId ?? "";
|
||||
const missionHref = `/missions/active?missionInstanceId=${encodeURIComponent(action.missionInstanceId)}`;
|
||||
const missionHref = missionDetailHref(action.missionInstanceId);
|
||||
const href = hrefFromPayload ??
|
||||
(serviceId.includes("interview") ? `/agents/interview/setup?source=mission&missionInstanceId=${encodeURIComponent(action.missionInstanceId)}&missionId=${encodeURIComponent(action.missionId)}${action.stageId ? `&stageId=${encodeURIComponent(action.stageId)}` : ""}` :
|
||||
serviceId.includes("roleplay") ? `/agents/roleplay/setup?source=mission&missionInstanceId=${encodeURIComponent(action.missionInstanceId)}&missionId=${encodeURIComponent(action.missionId)}${action.stageId ? `&stageId=${encodeURIComponent(action.stageId)}` : ""}` :
|
||||
serviceId.includes("resume") ? `/agents/resume?source=mission&missionInstanceId=${encodeURIComponent(action.missionInstanceId)}&missionId=${encodeURIComponent(action.missionId)}${action.stageId ? `&stageId=${encodeURIComponent(action.stageId)}` : ""}` : missionHref);
|
||||
(serviceId.includes("interview") ? serviceHref("interview", action.missionInstanceId, action.missionId, action.stageId ?? undefined) :
|
||||
serviceId.includes("roleplay") ? serviceHref("roleplay", action.missionInstanceId, action.missionId, action.stageId ?? undefined) :
|
||||
serviceId.includes("resume") ? serviceHref("resume", action.missionInstanceId, action.missionId, action.stageId ?? undefined) : missionHref);
|
||||
|
||||
if (action.mode === "approval_required") return { ctaLabel: "Review", ctaHref: missionHref };
|
||||
if (action.mode === "user_input_required") return { ctaLabel: "Answer", ctaHref: missionHref };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
|
||||
import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js";
|
||||
import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, missionDetailHref, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js";
|
||||
|
||||
export const personalBrandOpportunityReducer: MissionReducer = {
|
||||
missionId: "personal-brand-opportunity-engine",
|
||||
@@ -61,7 +61,7 @@ export const personalBrandOpportunityReducer: MissionReducer = {
|
||||
mode: "suggestion",
|
||||
title: "Turn this pitch into weekly content pillars",
|
||||
body: "Use the networking practice feedback to draft 3 credibility themes for weekly posts.",
|
||||
payload: { weakAreas, href: `/missions/active?missionInstanceId=${encodeURIComponent(activeMission.instanceId)}` },
|
||||
payload: { weakAreas, href: missionDetailHref(activeMission.instanceId) },
|
||||
sourceEventId: event.id,
|
||||
idempotencyKey: `${activeMission.instanceId}:content-pillars:${event.id}`,
|
||||
priority: 82,
|
||||
|
||||
@@ -141,3 +141,7 @@ export function serviceHref(service: "resume" | "interview" | "roleplay" | "qsco
|
||||
if (service === "resume") return `/agents/resume?${params.toString()}`;
|
||||
return `/agents/qscore?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function missionDetailHref(missionInstanceId: string) {
|
||||
return `/missions/${encodeURIComponent(missionInstanceId)}`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MissionSnapshot, MissionStage } from "../actors/missions/types.js";
|
||||
import { missionDetailHref } from "./reducer-helpers.js";
|
||||
|
||||
export type MissionSuggestionType = "action" | "practice" | "review" | "artifact" | "blocked" | "insight";
|
||||
export type MissionSuggestionUrgency = "now" | "today" | "soon" | "calm";
|
||||
@@ -103,7 +104,7 @@ function ctaFor(stage: MissionStage, snapshot: MissionSnapshot, context?: Missio
|
||||
return { label: "Open resume", href: `/agents/resume?${params.toString()}` };
|
||||
}
|
||||
if (role === "Q Score") return { label: "View Q Score", href: `/agents/qscore?${params.toString()}` };
|
||||
return { label: "Continue", href: `/missions/active?${params.toString()}` };
|
||||
return { label: "Continue", href: `${missionDetailHref(snapshot.instanceId)}?${params.toString()}` };
|
||||
}
|
||||
|
||||
function suggestionId(snapshot: MissionSnapshot, stage: MissionStage, suffix: string) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { buildDeterministicMissionSuggestions } from "../missions/suggestions.js
|
||||
import { createMissionAction, getMissionAction, listMissionActions, updateMissionActionStatus } from "../missions/actions.js";
|
||||
import { recordGrowEvent } from "../events/record-grow-event.js";
|
||||
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
|
||||
import { missionDetailHref } from "../missions/reducer-helpers.js";
|
||||
|
||||
let _client: Client<Registry> | null = null;
|
||||
function getClient(): Client<Registry> {
|
||||
@@ -255,7 +256,7 @@ export function missionRoutes() {
|
||||
if (active?.mission.actorType) {
|
||||
await missionActorFor(userId, active.mission.instanceId, active.mission.actorType).runAction({ actionId: existing.id }).catch(() => undefined);
|
||||
}
|
||||
const href = typeof existing.payload?.href === "string" ? existing.payload.href : `/missions/active?missionInstanceId=${encodeURIComponent(existing.missionInstanceId)}`;
|
||||
const href = typeof existing.payload?.href === "string" ? existing.payload.href : missionDetailHref(existing.missionInstanceId);
|
||||
const action = await updateMissionActionStatus(userId, existing.id, {
|
||||
status: "done",
|
||||
result: {
|
||||
|
||||
@@ -43,6 +43,31 @@ function missionFromBody(body: JsonObject): Record<string, unknown> | undefined
|
||||
return mission && typeof mission === "object" && !Array.isArray(mission) ? (mission as Record<string, unknown>) : undefined;
|
||||
}
|
||||
|
||||
function missionFromRequest(req: Request, body?: JsonObject): Record<string, unknown> | undefined {
|
||||
const fromBody = body ? missionFromBody(body) : undefined;
|
||||
if (fromBody) return fromBody;
|
||||
|
||||
const url = new URL(req.url);
|
||||
const instanceId = getString(url.searchParams.get("missionInstanceId"));
|
||||
const missionId = getString(url.searchParams.get("missionId"));
|
||||
const stageId = getString(url.searchParams.get("stageId"));
|
||||
const source = getString(url.searchParams.get("source"));
|
||||
|
||||
if (!instanceId && !missionId && !stageId) return undefined;
|
||||
return {
|
||||
instanceId,
|
||||
missionId,
|
||||
stageId,
|
||||
source: source ?? "mission",
|
||||
};
|
||||
}
|
||||
|
||||
function stripMissionFromBody(body: JsonObject): JsonObject {
|
||||
if (!("mission" in body)) return body;
|
||||
const { mission: _mission, ...rest } = body;
|
||||
return rest;
|
||||
}
|
||||
|
||||
async function recordGatewayEvent(input: {
|
||||
userId: string;
|
||||
source: string;
|
||||
@@ -107,8 +132,13 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) {
|
||||
.replace(/^resumes\/([^/]+)\/analyze$/, "ai/analyze/$1")
|
||||
.replace(/^resumes\/([^/]+)\/suggestions$/, "ai/suggestions/$1")
|
||||
.replace(/^resumes\/([^/]+)\/preview$/, "export/resumes/$1/preview");
|
||||
const forwardedQuery = new URLSearchParams(incoming.searchParams);
|
||||
forwardedQuery.delete("missionInstanceId");
|
||||
forwardedQuery.delete("missionId");
|
||||
forwardedQuery.delete("stageId");
|
||||
forwardedQuery.delete("source");
|
||||
const target = new URL(
|
||||
`/api/v1/${normalizedRest}${incoming.search}`,
|
||||
`/api/v1/${normalizedRest}${forwardedQuery.toString() ? `?${forwardedQuery.toString()}` : ""}`,
|
||||
config.resumeServiceUrl.replace(/\/$/, ""),
|
||||
);
|
||||
|
||||
@@ -121,10 +151,16 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) {
|
||||
const method = req.method.toUpperCase();
|
||||
const body = ["GET", "HEAD"].includes(method) ? undefined : await req.arrayBuffer();
|
||||
const requestJson = parseJsonBody(body, headers);
|
||||
const mission = missionFromRequest(req, requestJson);
|
||||
const forwardBody =
|
||||
body && headers.get("content-type")?.includes("application/json")
|
||||
? Buffer.from(JSON.stringify(stripMissionFromBody(requestJson)))
|
||||
: body;
|
||||
if (forwardBody !== body) headers.delete("content-length");
|
||||
const res = await fetch(target, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
body: forwardBody,
|
||||
});
|
||||
|
||||
if (method === "GET" || method === "HEAD") {
|
||||
@@ -146,7 +182,7 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) {
|
||||
resumeId: getString(responseObj.resume_id ?? responseObj.resumeId ?? responseObj.id) ?? getString(requestJson.resume_id ?? requestJson.resumeId),
|
||||
externalId: getString(responseObj.resume_id ?? responseObj.resumeId ?? responseObj.id) ?? getString(requestJson.resume_id ?? requestJson.resumeId),
|
||||
},
|
||||
mission: missionFromBody(requestJson),
|
||||
mission,
|
||||
}).catch((err) => log.warn({ err, path: normalizedRest }, "failed to record resume gateway event"));
|
||||
|
||||
return new Response(responseBuffer, {
|
||||
@@ -308,11 +344,12 @@ function composeCandidateProfile(userContext: Record<string, unknown>): string {
|
||||
}
|
||||
|
||||
async function buildPersonalizedConfigurePayload(req: Request, body: JsonObject, userId: string): Promise<JsonObject> {
|
||||
const { mission: _mission, ...rest } = body;
|
||||
const userContext = await resolveGrowUserContext(req, userId).catch((err) => {
|
||||
log.warn({ err, userId }, "failed to resolve Grow user context for interview configure");
|
||||
return {} as Record<string, unknown>;
|
||||
});
|
||||
const incomingContext = isRecord(body.context) ? body.context : {};
|
||||
const incomingContext = isRecord(rest.context) ? rest.context : {};
|
||||
const context: Record<string, unknown> = {
|
||||
...incomingContext,
|
||||
candidate_name: getString(incomingContext.candidate_name) ?? getString(userContext.first_name) ?? "",
|
||||
@@ -328,19 +365,20 @@ async function buildPersonalizedConfigurePayload(req: Request, body: JsonObject,
|
||||
}
|
||||
|
||||
return {
|
||||
...body,
|
||||
user_id: String(body.user_id ?? userId),
|
||||
org_id: String(body.org_id ?? "growqr"),
|
||||
...rest,
|
||||
user_id: String(rest.user_id ?? userId),
|
||||
org_id: String(rest.org_id ?? "growqr"),
|
||||
context,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildPersonalizedRoleplayConfigurePayload(req: Request, body: JsonObject, userId: string): Promise<JsonObject> {
|
||||
const { mission: _mission, ...rest } = body;
|
||||
const userContext = await resolveGrowUserContext(req, userId).catch((err) => {
|
||||
log.warn({ err, userId }, "failed to resolve Grow user context for roleplay configure");
|
||||
return {} as Record<string, unknown>;
|
||||
});
|
||||
const incomingMetadata = isRecord(body.metadata) ? body.metadata : {};
|
||||
const incomingMetadata = isRecord(rest.metadata) ? rest.metadata : {};
|
||||
const metadata: Record<string, unknown> = {
|
||||
...incomingMetadata,
|
||||
candidate_name: getString(incomingMetadata.candidate_name) ?? getString(userContext.first_name) ?? "",
|
||||
@@ -359,11 +397,11 @@ async function buildPersonalizedRoleplayConfigurePayload(req: Request, body: Jso
|
||||
}
|
||||
|
||||
return {
|
||||
...body,
|
||||
user_id: String(body.user_id ?? userId),
|
||||
org_id: String(body.org_id ?? "growqr"),
|
||||
...rest,
|
||||
user_id: String(rest.user_id ?? userId),
|
||||
org_id: String(rest.org_id ?? "growqr"),
|
||||
metadata,
|
||||
qscore: (body.qscore as JsonObject | undefined) ?? (isRecord(userContext.qscore) ? userContext.qscore : DEFAULT_QSCORE),
|
||||
qscore: (rest.qscore as JsonObject | undefined) ?? (isRecord(userContext.qscore) ? userContext.qscore : DEFAULT_QSCORE),
|
||||
user_context: userContext,
|
||||
};
|
||||
}
|
||||
@@ -526,6 +564,7 @@ export function serviceRoutes() {
|
||||
app.post("/interview/configure", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = await c.req.json<JsonObject>();
|
||||
const mission = missionFromRequest(c.req.raw, body);
|
||||
const payload = await buildPersonalizedConfigurePayload(c.req.raw, body, userId);
|
||||
const result = await interviewService.configure(payload);
|
||||
const resultObj = result as Record<string, unknown>;
|
||||
@@ -535,7 +574,7 @@ export function serviceRoutes() {
|
||||
type: "interview.configured",
|
||||
payload: { request: payload, result: resultObj },
|
||||
correlation: { sessionId: getSessionId(resultObj), requestId: getString(body.request_id) },
|
||||
mission: missionFromBody(body),
|
||||
mission,
|
||||
}).catch((err) => log.warn({ err }, "failed to record interview configured event"));
|
||||
return c.json(result);
|
||||
});
|
||||
@@ -586,6 +625,7 @@ export function serviceRoutes() {
|
||||
app.post("/roleplay/configure", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = await c.req.json<JsonObject>();
|
||||
const mission = missionFromRequest(c.req.raw, body);
|
||||
const payload = await buildPersonalizedRoleplayConfigurePayload(c.req.raw, body, userId);
|
||||
const result = await roleplayService.configure(payload);
|
||||
const resultObj = result as Record<string, unknown>;
|
||||
@@ -595,7 +635,7 @@ export function serviceRoutes() {
|
||||
type: "roleplay.configured",
|
||||
payload: { request: payload, result: resultObj },
|
||||
correlation: { sessionId: getSessionId(resultObj), requestId: getString(body.request_id) },
|
||||
mission: missionFromBody(body),
|
||||
mission,
|
||||
}).catch((err) => log.warn({ err }, "failed to record roleplay configured event"));
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user