From 92ab41404801334c86b03d8fcfba0cb1c8965a8d Mon Sep 17 00:00:00 2001 From: NinjasPyajamas Date: Wed, 10 Jun 2026 02:49:18 +0530 Subject: [PATCH] feat: enhance mission detail handling and update hrefs across services --- src/home/home-feed.ts | 12 ++-- src/home/types.ts | 16 ++++- src/missions/actions.ts | 9 +-- .../reducer.ts | 4 +- src/missions/reducer-helpers.ts | 4 ++ src/missions/suggestions.ts | 3 +- src/routes/missions.ts | 3 +- src/routes/services.ts | 68 +++++++++++++++---- 8 files changed, 91 insertions(+), 28 deletions(-) diff --git a/src/home/home-feed.ts b/src/home/home-feed.ts index 18388ec..d384f9f 100644 --- a/src/home/home-feed.ts +++ b/src/home/home-feed.ts @@ -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, }); diff --git a/src/home/types.ts b/src/home/types.ts index f8cae38..43c4115 100644 --- a/src/home/types.ts +++ b/src/home/types.ts @@ -48,7 +48,14 @@ export const MODULE_META: Record href === prefix || href.startsWith(`${prefix}?`)); + return ALLOWED_NOTIFICATION_HREF_PREFIXES.some((prefix) => + prefix.endsWith("/") + ? href.startsWith(prefix) + : href === prefix || href.startsWith(`${prefix}?`), + ); } diff --git a/src/missions/actions.ts b/src/missions/actions.ts index 4fb77c7..1e5a02b 100644 --- a/src/missions/actions.ts +++ b/src/missions/actions.ts @@ -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 : {}; 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 }; diff --git a/src/missions/personal-brand-opportunity-engine/reducer.ts b/src/missions/personal-brand-opportunity-engine/reducer.ts index 36be3bb..b64b429 100644 --- a/src/missions/personal-brand-opportunity-engine/reducer.ts +++ b/src/missions/personal-brand-opportunity-engine/reducer.ts @@ -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, diff --git a/src/missions/reducer-helpers.ts b/src/missions/reducer-helpers.ts index ae7f391..313c3e5 100644 --- a/src/missions/reducer-helpers.ts +++ b/src/missions/reducer-helpers.ts @@ -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)}`; +} diff --git a/src/missions/suggestions.ts b/src/missions/suggestions.ts index 7fd2a58..109e7f8 100644 --- a/src/missions/suggestions.ts +++ b/src/missions/suggestions.ts @@ -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) { diff --git a/src/routes/missions.ts b/src/routes/missions.ts index 72ed43c..cfe3f67 100644 --- a/src/routes/missions.ts +++ b/src/routes/missions.ts @@ -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 | null = null; function getClient(): Client { @@ -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: { diff --git a/src/routes/services.ts b/src/routes/services.ts index 299ef48..3b92896 100644 --- a/src/routes/services.ts +++ b/src/routes/services.ts @@ -43,6 +43,31 @@ function missionFromBody(body: JsonObject): Record | undefined return mission && typeof mission === "object" && !Array.isArray(mission) ? (mission as Record) : undefined; } +function missionFromRequest(req: Request, body?: JsonObject): Record | 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 { } async function buildPersonalizedConfigurePayload(req: Request, body: JsonObject, userId: string): Promise { + 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; }); - const incomingContext = isRecord(body.context) ? body.context : {}; + const incomingContext = isRecord(rest.context) ? rest.context : {}; const context: Record = { ...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 { + 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; }); - const incomingMetadata = isRecord(body.metadata) ? body.metadata : {}; + const incomingMetadata = isRecord(rest.metadata) ? rest.metadata : {}; const metadata: Record = { ...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(); + 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; @@ -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(); + 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; @@ -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); });