feat: enhance mission detail handling and update hrefs across services

This commit is contained in:
2026-06-10 02:49:18 +05:30
parent 9fd478c095
commit 92ab414048
8 changed files with 91 additions and 28 deletions

View File

@@ -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,
});

View File

@@ -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}?`),
);
}

View File

@@ -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 };

View File

@@ -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,

View File

@@ -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)}`;
}

View File

@@ -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) {

View File

@@ -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: {

View File

@@ -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);
});