diff --git a/src/home/home-feed-agent.ts b/src/home/home-feed-agent.ts index cfdd665..52a8009 100644 --- a/src/home/home-feed-agent.ts +++ b/src/home/home-feed-agent.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { getConversationModel } from "../actors/conversation/agent.js"; import { config } from "../config.js"; import { log } from "../log.js"; -import { ALLOWED_NOTIFICATION_HREFS, MODULE_IDS, type HomeModuleId, type HomeNotification, type HomeUrgency } from "./types.js"; +import { isAllowedNotificationHref, MODULE_IDS, type HomeModuleId, type HomeNotification, type HomeUrgency } from "./types.js"; const notificationSchema = z.object({ moduleId: z.enum(MODULE_IDS as [HomeModuleId, ...HomeModuleId[]]), @@ -39,12 +39,8 @@ Use minimal iPhone-notification copy: title <= 72 chars, subtitle <= 110 chars, Use urgency truthfully: now = needs immediate user action, today = useful today, soon = next few days, calm = informational.`; function sanitizeHref(href: string, moduleId: HomeModuleId) { - if (ALLOWED_NOTIFICATION_HREFS.has(href)) return href; - if (href.startsWith("/agents/resume")) return "/agents/resume"; - if (href.startsWith("/agents/interview")) return "/agents/interview"; - if (href.startsWith("/agents/roleplay")) return "/agents/roleplay"; - if (href.startsWith("/agents/qscore")) return "/agents/qscore"; - if (href.startsWith("/missions")) return "/missions"; + if (isAllowedNotificationHref(href)) return href; + if (href.startsWith("/missions")) return "/missions/active"; if (href.startsWith("/social")) return "/social"; if (href.startsWith("/pathways")) return "/pathways"; if (href.startsWith("/rewards")) return "/rewards"; diff --git a/src/home/home-feed.ts b/src/home/home-feed.ts index e6ec3ae..91f9ccf 100644 --- a/src/home/home-feed.ts +++ b/src/home/home-feed.ts @@ -8,6 +8,7 @@ import { growQscoreProjectionState, missionArtifacts, missionServiceSessions, + missionSuggestions, qscoreSnapshots, users, type GrowHomeNotificationRow, @@ -16,7 +17,7 @@ import { import { interviewService, resumeService, roleplayService } from "../services/product-service-clients.js"; import { refineHomeNotificationsWithAgent } from "./home-feed-agent.js"; import { - ALLOWED_NOTIFICATION_HREFS, + isAllowedNotificationHref, MODULE_IDS, MODULE_META, type HomeFeedResponse, @@ -35,7 +36,7 @@ const SERVICE_HREFS = { interview: "/agents/interview", roleplay: "/agents/roleplay", qscore: "/agents/qscore", - mission: "/missions", + mission: "/missions/active", social: "/social", pathways: "/pathways", rewards: "/rewards", @@ -50,10 +51,13 @@ type HomeContext = { qscore: { score: number; signalCount: number; summary: string | null; dimensions: Record | null } | undefined; qscoreSignals: Array<{ signalId: string; score: number; source: string | null; updatedAt: Date }>; activeMissions: Array<{ instanceId: string; missionId: string; title: string; status: string; progressPercent: number; currentStageId: string | null; updatedAt: Date }>; + missionSuggestions: Array<{ id: string; missionInstanceId: string; missionId: string; stageId: string | null; role: string; type: string; title: string; body: string; reason: string | null; priority: number; urgency: string; ctaLabel: string; ctaHref: string; updatedAt: Date }>; sessions: Array<{ serviceId: string; externalId: string; status: string; updatedAt: Date; metadata: Record | null }>; artifacts: Array<{ serviceId: string | null; type: string; title: string; status: string; summary: string | null; createdAt: Date }>; events: Array<{ source: string; type: string; occurredAt: Date; payload: Record }>; serviceStates: Record; + userProfile?: Record; + preferences: Record; }; function isRecord(value: unknown): value is Record { @@ -64,13 +68,68 @@ function numberFrom(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } +function arrayOfStrings(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0) : []; +} + +function recordOf(value: unknown): Record { + return isRecord(value) ? value : {}; +} + +function profileFromPreferences(preferences: Record) { + const onboarding = recordOf(preferences.onboarding); + const interview = recordOf(preferences.interview_preferences); + const resume = recordOf(preferences.resume_preferences); + const mission = recordOf(preferences.mission_preferences); + const targetRoles = arrayOfStrings(preferences.target_roles); + const targetCompanies = arrayOfStrings(preferences.target_companies); + const focusAreas = arrayOfStrings(interview.focus_areas); + return { + targetRole: targetRoles[0] ?? (typeof resume.target_title === "string" ? resume.target_title : "Senior Data Scientist"), + targetCompany: targetCompanies[0] ?? "target company", + industry: typeof preferences.industry === "string" ? preferences.industry : "AI / SaaS", + focusAreas, + weakSpots: arrayOfStrings(interview.weak_spots), + jobDescription: typeof interview.job_description === "string" ? interview.job_description : undefined, + activeGoal: typeof mission.active_goal === "string" ? mission.active_goal : typeof onboarding.goal === "string" ? onboarding.goal : undefined, + onboardingComplete: Boolean(onboarding.completed_at), + }; +} + +function serviceHref(service: "resume" | "interview" | "roleplay" | "qscore", ctx: HomeContext, mission?: { instanceId?: string; missionId?: string; stageId?: string | null }) { + const profile = profileFromPreferences(ctx.preferences); + const params = new URLSearchParams({ source: "home" }); + if (mission?.instanceId) params.set("missionInstanceId", mission.instanceId); + if (mission?.missionId) params.set("missionId", mission.missionId); + if (mission?.stageId) params.set("stageId", mission.stageId); + params.set("targetRole", profile.targetRole); + if (profile.targetCompany !== "target company") params.set("targetCompany", profile.targetCompany); + if (profile.industry) params.set("industry", profile.industry); + if (profile.focusAreas.length) params.set("focusAreas", profile.focusAreas.slice(0, 4).join(",")); + if (profile.weakSpots.length) params.set("weakSpots", profile.weakSpots.slice(0, 3).join(",")); + if (profile.jobDescription) params.set("jobDescription", profile.jobDescription.slice(0, 900)); + if (service === "interview") return `/agents/interview/setup?${params.toString()}`; + if (service === "roleplay") return `/agents/roleplay/setup?${params.toString()}`; + if (service === "resume") return `/agents/resume?${params.toString()}`; + return `/agents/qscore?${params.toString()}`; +} + +function sourceFromSuggestionRole(role: string): HomeSource { + const value = role.toLowerCase(); + if (value.includes("resume")) return "resume"; + if (value.includes("roleplay")) return "roleplay"; + if (value.includes("interview")) return "interview"; + if (value.includes("q")) return "qscore"; + return "mission"; +} + function sanitizeUrgency(value: string): HomeUrgency { if (value === "now" || value === "today" || value === "soon" || value === "calm") return value; return "calm"; } function sanitizeHref(href: string | undefined, fallback: string) { - if (href && ALLOWED_NOTIFICATION_HREFS.has(href)) return href; + if (href && isAllowedNotificationHref(href)) return href; return fallback; } @@ -97,7 +156,9 @@ function hasAnyRealActivity(ctx: HomeContext) { ctx.activeMissions.length || ctx.sessions.length || ctx.artifacts.length || - ctx.events.length, + ctx.events.length || + ctx.missionSuggestions.length || + profileFromPreferences(ctx.preferences).onboardingComplete, ); } @@ -119,6 +180,7 @@ function buildDayOneSeeds(): SeedNotification[] { function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] { const seeds = buildDayOneSeeds().filter((seed) => seed.moduleId === "pathways" || seed.moduleId === "rewards"); + const profile = profileFromPreferences(ctx.preferences); const qscore = ctx.qscore?.score ?? Math.round(ctx.qscoreSignals.reduce((sum, s) => sum + s.score, 0) / Math.max(ctx.qscoreSignals.length, 1)); const ats = latestScore(ctx.qscoreSignals, "resume.ats_compatibility"); const interviewOverall = latestScore(ctx.qscoreSignals, "interview.overall_score"); @@ -130,14 +192,55 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] { const roleplayReview = serviceEvent(ctx, "roleplay.", "review"); const resumeAnalysis = serviceEvent(ctx, "resume.", "analysis"); + 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); + pushSeed(seeds, { + moduleId: "suggestions", + title: suggestion.title, + subtitle: suggestion.body, + tag: suggestion.ctaLabel.replace(/\s+/g, " ").slice(0, 14), + urgency: sanitizeUrgency(suggestion.urgency), + href, + source, + reason: suggestion.reason ?? undefined, + priority: Math.max(100, suggestion.priority + 10), + }); + pushSeed(seeds, { + moduleId: suggestion.role.toLowerCase().includes("resume") || suggestion.role.toLowerCase().includes("interview") || suggestion.role.toLowerCase().includes("roleplay") ? "productivity" : "missions", + title: `${suggestion.role}: ${suggestion.title}`, + subtitle: mission ? `${mission.title} · ${suggestion.body}` : suggestion.body, + tag: suggestion.urgency === "now" ? "Now" : suggestion.urgency === "today" ? "Today" : "Next", + urgency: sanitizeUrgency(suggestion.urgency), + href, + source, + reason: suggestion.reason ?? undefined, + priority: suggestion.priority, + }); + } + + if (profile.onboardingComplete) { + pushSeed(seeds, { + moduleId: "suggestions", + title: `${profile.targetRole} plan is calibrated`, + subtitle: profile.activeGoal ?? `Today's recommendations are tuned for ${profile.targetRole}${profile.targetCompany !== "target company" ? ` at ${profile.targetCompany}` : ""}.`, + tag: "Profile", + urgency: "today", + href: "/suggestions", + source: "system", + priority: 91, + }); + } + if (ctx.qscore || ctx.qscoreSignals.length) { pushSeed(seeds, { moduleId: "suggestions", title: qscore >= 80 ? "Protect your Q Score momentum" : "Raise your Q Score next", - subtitle: qscore >= 80 ? `Readiness is trending at ${qscore}. Keep one proof action moving.` : `Current estimate is ${qscore || 64}. Resume + mock practice are the fastest signals.`, + subtitle: qscore >= 80 ? `Readiness is trending at ${qscore}. Keep one proof action moving for ${profile.targetRole}.` : `Current estimate is ${qscore || 64}. Resume + mock practice are fastest for ${profile.targetRole}.`, tag: "Q Score", urgency: qscore >= 80 ? "today" : "now", - href: SERVICE_HREFS.qscore, + href: serviceHref("qscore", ctx), source: "qscore", priority: 95, }); @@ -147,10 +250,10 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] { pushSeed(seeds, { moduleId: "suggestions", title: ats >= 80 ? "ATS is demo-ready" : "Resume ATS needs one pass", - subtitle: ats >= 80 ? `ATS ${Math.round(ats)} — review role-specific keywords before applying.` : `ATS ${Math.round(ats)} — add JD keywords and measurable bullets.`, + subtitle: ats >= 80 ? `ATS ${Math.round(ats)} — review ${profile.targetRole} keywords before applying.` : `ATS ${Math.round(ats)} — add JD keywords and measurable data-science bullets.`, tag: ats >= 80 ? "Ready" : "Fix", urgency: ats >= 80 ? "today" : "now", - href: SERVICE_HREFS.resume, + href: serviceHref("resume", ctx), source: "resume", priority: 92, }); @@ -163,7 +266,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: SERVICE_HREFS.mission, + href: `/missions/active?missionInstanceId=${encodeURIComponent(mission.instanceId)}`, source: "mission", priority: 90 - mission.progressPercent, }); @@ -186,7 +289,7 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] { pushSeed(seeds, { moduleId: "social", title: "Turn proof into LinkedIn updates", - subtitle: ctx.artifacts.length ? `${ctx.artifacts.length} artifact${ctx.artifacts.length === 1 ? "" : "s"} can feed headline, featured, or post ideas.` : "Connect LinkedIn and use mission proof to improve your profile.", + subtitle: ctx.artifacts.length ? `${ctx.artifacts.length} artifact${ctx.artifacts.length === 1 ? "" : "s"} can feed headline, featured, or post ideas.` : `Connect LinkedIn and use ${profile.targetRole} proof to improve your profile.`, tag: ctx.artifacts.length ? "Proof" : "Setup", urgency: ctx.artifacts.length ? "today" : "soon", href: SERVICE_HREFS.social, @@ -198,51 +301,52 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] { pushSeed(seeds, { moduleId: "productivity", title: ats !== undefined ? `Resume ATS ${Math.round(ats)}` : "Resume analysis is ready", - subtitle: ats !== undefined && ats >= 80 ? "Use this version for role-fit scan or final polish." : "Open Resume Builder for recommendations and bullet fixes.", + subtitle: ats !== undefined && ats >= 80 ? `Use this version for ${profile.targetRole} role-fit scan or final polish.` : "Open Resume Builder for recommendations and bullet fixes.", tag: "Resume", urgency: ats !== undefined && ats < 75 ? "now" : "today", - href: SERVICE_HREFS.resume, + href: serviceHref("resume", ctx), source: "resume", priority: 90, }); } + const firstMission = ctx.activeMissions[0]; if (interviewReview || interviewOverall !== undefined || interviewSession) { pushSeed(seeds, { moduleId: "productivity", title: interviewOverall !== undefined ? `Mock interview score ${Math.round(interviewOverall)}` : "Mock interview review is tracking", - subtitle: interviewReview?.type.includes("processing") ? "Review is still processing; check back from the interview page." : "Open interview practice for review, next drill, or a new session.", + subtitle: interviewReview?.type.includes("processing") ? "Review is still processing; check back from the interview page." : `Open ${profile.targetRole} interview practice for review, next drill, or a new session.`, tag: interviewReview?.type.includes("processing") ? "Wait" : "Mock", urgency: interviewReview?.type.includes("processing") ? "soon" : "today", - href: SERVICE_HREFS.interview, + href: serviceHref("interview", ctx, { instanceId: firstMission?.instanceId, missionId: firstMission?.missionId, stageId: firstMission?.currentStageId }), source: "interview", priority: 86, }); } else { - pushSeed(seeds, { moduleId: "productivity", title: "Schedule a mock interview", subtitle: "Generate a behavioral or role-related session from your target role.", tag: "Mock", urgency: "soon", href: SERVICE_HREFS.interview, source: "interview", priority: 72 }); + pushSeed(seeds, { moduleId: "productivity", title: `Schedule a ${profile.targetRole} mock`, subtitle: "Generate a behavioral or role-related session from your target role.", tag: "Mock", urgency: "soon", href: serviceHref("interview", ctx, { instanceId: firstMission?.instanceId, missionId: firstMission?.missionId, stageId: firstMission?.currentStageId }), source: "interview", priority: 72 }); } if (roleplayReview || roleplayComms !== undefined || roleplaySession) { pushSeed(seeds, { moduleId: "productivity", title: roleplayComms !== undefined ? `Roleplay communication ${Math.round(roleplayComms)}` : "Roleplay scenario is ready", - subtitle: "Practice recruiter, manager, salary, or stakeholder conversations.", + subtitle: `Practice recruiter, manager, salary, or stakeholder conversations for ${profile.targetRole}.`, tag: "Roleplay", urgency: "soon", - href: SERVICE_HREFS.roleplay, + href: serviceHref("roleplay", ctx, { instanceId: firstMission?.instanceId, missionId: firstMission?.missionId, stageId: firstMission?.currentStageId }), source: "roleplay", priority: 78, }); } if (!ctx.activeMissions.length) { - pushSeed(seeds, { moduleId: "missions", title: "Start Interview-to-Offer", subtitle: "Bundle resume fit, mock practice, and Q Score deltas into one journey.", tag: "Begin", urgency: "today", href: SERVICE_HREFS.mission, source: "mission", priority: 80 }); + pushSeed(seeds, { moduleId: "missions", title: "Start Interview-to-Offer", subtitle: `Bundle resume fit, mock practice, and Q Score deltas for ${profile.targetRole}.`, tag: "Begin", urgency: "today", href: "/missions/available", source: "mission", priority: 80 }); } return seeds; } -async function collectContext(userId: string): Promise { +async function collectContext(userId: string, input: { userProfile?: Record; preferences?: Record } = {}): Promise { const [user] = await db.select({ id: users.id, email: users.email, displayName: users.displayName }).from(users).where(eq(users.id, userId)).limit(1); const [qscore] = await db.select().from(growQscoreProjectionState).where(eq(growQscoreProjectionState.userId, userId)).limit(1); const qscoreSignals = await db @@ -257,6 +361,27 @@ async function collectContext(userId: string): Promise { .where(eq(growActiveMissions.userId, userId)) .orderBy(desc(growActiveMissions.updatedAt)) .limit(6); + const suggestions = await db + .select({ + id: missionSuggestions.id, + missionInstanceId: missionSuggestions.missionInstanceId, + missionId: missionSuggestions.missionId, + stageId: missionSuggestions.stageId, + role: missionSuggestions.role, + type: missionSuggestions.type, + title: missionSuggestions.title, + body: missionSuggestions.body, + reason: missionSuggestions.reason, + priority: missionSuggestions.priority, + urgency: missionSuggestions.urgency, + ctaLabel: missionSuggestions.ctaLabel, + ctaHref: missionSuggestions.ctaHref, + updatedAt: missionSuggestions.updatedAt, + }) + .from(missionSuggestions) + .where(and(eq(missionSuggestions.userId, userId), eq(missionSuggestions.status, "active"))) + .orderBy(desc(missionSuggestions.priority), desc(missionSuggestions.updatedAt)) + .limit(12); const sessions = await db .select({ serviceId: missionServiceSessions.serviceId, externalId: missionServiceSessions.externalId, status: missionServiceSessions.status, updatedAt: missionServiceSessions.updatedAt, metadata: missionServiceSessions.metadata }) .from(missionServiceSessions) @@ -296,10 +421,13 @@ async function collectContext(userId: string): Promise { : undefined, qscoreSignals, activeMissions, + missionSuggestions: suggestions, sessions: sessions.map((s) => ({ ...s, metadata: isRecord(s.metadata) ? s.metadata : null })), artifacts, events: events.map((e) => ({ ...e, payload: isRecord(e.payload) ? e.payload : {} })), serviceStates: { resume: resumeState, interview: interviewState, roleplay: roleplayState }, + userProfile: input.userProfile, + preferences: input.preferences ?? {}, }; } @@ -430,8 +558,8 @@ async function buildIdentity(ctx: HomeContext) { }; } -export async function getHomeFeed(userId: string, opts: { refresh?: boolean } = {}): Promise { - const ctx = await collectContext(userId); +export async function getHomeFeed(userId: string, opts: { refresh?: boolean; userProfile?: Record; preferences?: Record } = {}): Promise { + const ctx = await collectContext(userId, { userProfile: opts.userProfile, preferences: opts.preferences }); const persisted = await readPersistedNotifications(userId); const newest = persisted[0]?.createdAt?.getTime() ?? 0; const hasDemo = persisted.some((row) => row.generatedBy === "demo"); @@ -459,6 +587,9 @@ export async function getHomeFeed(userId: string, opts: { refresh?: boolean } = artifacts: ctx.artifacts, recentEvents: ctx.events, serviceStates: ctx.serviceStates, + missionSuggestions: ctx.missionSuggestions, + userProfile: ctx.userProfile, + preferences: ctx.preferences, routeRules: SERVICE_HREFS, }, seeds: deterministic, diff --git a/src/home/types.ts b/src/home/types.ts index 109dd1f..c1c7b7a 100644 --- a/src/home/types.ts +++ b/src/home/types.ts @@ -53,12 +53,32 @@ export const MODULE_IDS: HomeModuleId[] = ["suggestions", "missions", "social", export const ALLOWED_NOTIFICATION_HREFS = new Set([ "/suggestions", "/missions", + "/missions/active", + "/missions/available", "/social", "/pathways", "/productivity", "/rewards", "/agents/resume", "/agents/interview", + "/agents/interview/setup", "/agents/roleplay", + "/agents/roleplay/setup", "/agents/qscore", ]); + +export const ALLOWED_NOTIFICATION_HREF_PREFIXES = [ + "/missions/active", + "/missions/available", + "/agents/resume", + "/agents/interview", + "/agents/interview/setup", + "/agents/roleplay", + "/agents/roleplay/setup", + "/agents/qscore", +] as const; + +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}?`)); +} diff --git a/src/routes/home.ts b/src/routes/home.ts index a0f2435..5fe946f 100644 --- a/src/routes/home.ts +++ b/src/routes/home.ts @@ -8,13 +8,29 @@ function canSeedDemo(userId: string) { return config.nodeEnv !== "production" || config.adminUserIds.includes(userId); } +async function getUserServiceProfile(req: Request): Promise<{ userProfile?: Record; preferences?: Record }> { + const target = new URL("/api/v1/users/me", config.userServiceUrl.replace(/\/$/, "")); + const headers = new Headers(req.headers); + headers.delete("host"); + headers.delete("cookie"); + const res = await fetch(target, { method: "GET", headers }); + if (!res.ok) return {}; + const userProfile = await res.json().catch(() => null) as Record | null; + const preferences = userProfile?.preferences; + return { + userProfile: userProfile ?? undefined, + preferences: preferences && typeof preferences === "object" && !Array.isArray(preferences) ? preferences as Record : {}, + }; +} + export function homeRoutes() { const app = new Hono(); app.use("*", requireUser); app.get("/feed", async (c) => { const refresh = c.req.query("refresh") === "1" || c.req.query("refresh") === "true"; - return c.json(await getHomeFeed(c.get("userId"), { refresh })); + const profile = await getUserServiceProfile(c.req.raw); + return c.json(await getHomeFeed(c.get("userId"), { refresh, ...profile })); }); app.post("/notifications/:id/dismiss", async (c) => {