diff --git a/drizzle/0009_mission_suggestions.sql b/drizzle/0009_mission_suggestions.sql new file mode 100644 index 0000000..1ab6865 --- /dev/null +++ b/drizzle/0009_mission_suggestions.sql @@ -0,0 +1,47 @@ +CREATE TABLE IF NOT EXISTS "mission_suggestions" ( + "id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL, + "user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade, + "mission_instance_id" text NOT NULL REFERENCES "grow_active_missions"("instance_id") ON DELETE cascade, + "mission_id" text NOT NULL, + "stage_id" text, + "role" text NOT NULL, + "type" text NOT NULL, + "title" text NOT NULL, + "body" text NOT NULL, + "reason" text, + "priority" integer DEFAULT 0 NOT NULL, + "urgency" text DEFAULT 'calm' NOT NULL, + "status" text DEFAULT 'active' NOT NULL, + "cta_label" text NOT NULL, + "cta_href" text NOT NULL, + "source_refs" jsonb DEFAULT '{}'::jsonb NOT NULL, + "generated_by" text DEFAULT 'deterministic' NOT NULL, + "expires_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE INDEX IF NOT EXISTS "mission_suggestions_mission_idx" ON "mission_suggestions" ("user_id", "mission_instance_id", "status", "priority"); +CREATE INDEX IF NOT EXISTS "mission_suggestions_role_idx" ON "mission_suggestions" ("mission_instance_id", "role", "status"); +CREATE INDEX IF NOT EXISTS "mission_suggestions_expiry_idx" ON "mission_suggestions" ("expires_at"); + +CREATE TABLE IF NOT EXISTS "mission_coach_runs" ( + "id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL, + "user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade, + "mission_instance_id" text NOT NULL REFERENCES "grow_active_missions"("instance_id") ON DELETE cascade, + "mission_id" text NOT NULL, + "status" text DEFAULT 'running' NOT NULL, + "window_start" timestamp with time zone NOT NULL, + "window_end" timestamp with time zone NOT NULL, + "summary" text, + "input_digest" jsonb DEFAULT '{}'::jsonb NOT NULL, + "output" jsonb DEFAULT '{}'::jsonb NOT NULL, + "model" text, + "prompt_version" text DEFAULT 'mission-coach-v1' NOT NULL, + "skill_version" text, + "error" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "completed_at" timestamp with time zone +); + +CREATE INDEX IF NOT EXISTS "mission_coach_runs_mission_idx" ON "mission_coach_runs" ("user_id", "mission_instance_id", "created_at"); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 59f9bce..9dbb06a 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1780481300000, "tag": "0008_home_notifications", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1780481400000, + "tag": "0009_mission_suggestions", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/auth/clerk.ts b/src/auth/clerk.ts index be5df57..05cab8c 100644 --- a/src/auth/clerk.ts +++ b/src/auth/clerk.ts @@ -5,6 +5,7 @@ import { config } from "../config.js"; import { db } from "../db/client.js"; import { users } from "../db/schema.js"; import { eq } from "drizzle-orm"; +import { log } from "../log.js"; export type AuthContext = { Variables: { @@ -86,15 +87,21 @@ export const requireUser = createMiddleware(async (c, next) => { // Lazy-mirror Clerk user → users table. let row = await db.query.users.findFirst({ where: eq(users.id, userId) }); if (!row) { - const clerkUser = await clerk.users.getUser(userId); - const email = - clerkUser.primaryEmailAddress?.emailAddress ?? - clerkUser.emailAddresses[0]?.emailAddress ?? - `${userId}@unknown.local`; - const displayName = - [clerkUser.firstName, clerkUser.lastName].filter(Boolean).join(" ") || - clerkUser.username || - null; + let email = `${userId}@unknown.local`; + let displayName: string | null = null; + try { + const clerkUser = await clerk.users.getUser(userId); + email = + clerkUser.primaryEmailAddress?.emailAddress ?? + clerkUser.emailAddresses[0]?.emailAddress ?? + email; + displayName = + [clerkUser.firstName, clerkUser.lastName].filter(Boolean).join(" ") || + clerkUser.username || + null; + } catch (err) { + log.warn({ err, userId }, "failed to hydrate Clerk user details; creating minimal backend mirror row"); + } const inserted = await db .insert(users) .values({ id: userId, email, displayName }) diff --git a/src/config.ts b/src/config.ts index 41a73d4..b8a1eb6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -73,6 +73,8 @@ export const config = { process.env.QSCORE_SERVICE_URL ?? "http://localhost:8000", resumeServiceUrl: process.env.RESUME_SERVICE_URL ?? "http://localhost:8002", + userServiceUrl: + process.env.USER_SERVICE_URL ?? "http://localhost:8003", resumePublicUrl: process.env.RESUME_PUBLIC_URL ?? process.env.RESUME_SERVICE_URL ?? "http://localhost:8002", matchmakingServiceUrl: diff --git a/src/db/schema.ts b/src/db/schema.ts index 3fc65b5..6d27f24 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -458,6 +458,60 @@ export const growQscoreProjectionState = pgTable("grow_qscore_projection_state", updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), }); +export const missionSuggestions = pgTable( + "mission_suggestions", + { + id: text("id").primaryKey().default(sql`gen_random_uuid()::text`), + userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + missionInstanceId: text("mission_instance_id").notNull().references(() => growActiveMissions.instanceId, { onDelete: "cascade" }), + missionId: text("mission_id").notNull(), + stageId: text("stage_id"), + role: text("role").notNull(), + type: text("type", { enum: ["action", "practice", "review", "artifact", "blocked", "insight"] }).notNull(), + title: text("title").notNull(), + body: text("body").notNull(), + reason: text("reason"), + priority: integer("priority").notNull().default(0), + urgency: text("urgency", { enum: ["now", "today", "soon", "calm"] }).notNull().default("calm"), + status: text("status", { enum: ["active", "done", "dismissed", "expired"] }).notNull().default("active"), + ctaLabel: text("cta_label").notNull(), + ctaHref: text("cta_href").notNull(), + sourceRefs: jsonb("source_refs").$type>().notNull().default(sql`'{}'::jsonb`), + generatedBy: text("generated_by", { enum: ["deterministic", "agent", "manual"] }).notNull().default("deterministic"), + expiresAt: timestamp("expires_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + }, + (t) => ({ + missionIdx: index("mission_suggestions_mission_idx").on(t.userId, t.missionInstanceId, t.status, t.priority), + roleIdx: index("mission_suggestions_role_idx").on(t.missionInstanceId, t.role, t.status), + expiryIdx: index("mission_suggestions_expiry_idx").on(t.expiresAt), + }), +); + +export const missionCoachRuns = pgTable( + "mission_coach_runs", + { + id: text("id").primaryKey().default(sql`gen_random_uuid()::text`), + userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + missionInstanceId: text("mission_instance_id").notNull().references(() => growActiveMissions.instanceId, { onDelete: "cascade" }), + missionId: text("mission_id").notNull(), + status: text("status", { enum: ["running", "completed", "failed"] }).notNull().default("running"), + windowStart: timestamp("window_start", { withTimezone: true }).notNull(), + windowEnd: timestamp("window_end", { withTimezone: true }).notNull(), + summary: text("summary"), + inputDigest: jsonb("input_digest").$type>().notNull().default(sql`'{}'::jsonb`), + output: jsonb("output").$type>().notNull().default(sql`'{}'::jsonb`), + model: text("model"), + promptVersion: text("prompt_version").notNull().default("mission-coach-v1"), + skillVersion: text("skill_version"), + error: text("error"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + completedAt: timestamp("completed_at", { withTimezone: true }), + }, + (t) => ({ missionIdx: index("mission_coach_runs_mission_idx").on(t.userId, t.missionInstanceId, t.createdAt) }), +); + export const growHomeNotifications = pgTable( "grow_home_notifications", { @@ -490,5 +544,8 @@ export const growHomeNotifications = pgTable( export type GrowEventRow = typeof growEvents.$inferSelect; export type NewGrowEvent = typeof growEvents.$inferInsert; +export type MissionSuggestionRow = typeof missionSuggestions.$inferSelect; +export type NewMissionSuggestion = typeof missionSuggestions.$inferInsert; +export type MissionCoachRunRow = typeof missionCoachRuns.$inferSelect; export type GrowHomeNotificationRow = typeof growHomeNotifications.$inferSelect; export type NewGrowHomeNotification = typeof growHomeNotifications.$inferInsert; diff --git a/src/grow/persistence.ts b/src/grow/persistence.ts index 38cea24..faf1a9b 100644 --- a/src/grow/persistence.ts +++ b/src/grow/persistence.ts @@ -1,7 +1,8 @@ import { asc, desc, eq, and } from "drizzle-orm"; import { db } from "../db/client.js"; -import { growActiveMissions, growConversationMessages, growConversations } from "../db/schema.js"; +import { growActiveMissions, growConversationMessages, growConversations, missionCoachRuns, missionSuggestions } from "../db/schema.js"; import type { GrowActiveMission, MissionSnapshot } from "../actors/missions/types.js"; +import type { MissionSuggestion } from "../missions/suggestions.js"; import type { ConversationMessage } from "../actors/conversation/types.js"; import type { GrowConversation } from "../actors/grow/types.js"; @@ -115,12 +116,153 @@ export async function upsertActiveMissionPg(userId: string, mission: GrowActiveM }); } +function activeMissionFromRow(row: typeof growActiveMissions.$inferSelect): GrowActiveMission { + const raw = (row.mission ?? {}) as Partial; + return { + instanceId: raw.instanceId ?? row.instanceId, + missionId: raw.missionId ?? (row.missionId as GrowActiveMission["missionId"]), + workflowId: raw.workflowId ?? row.workflowId, + title: raw.title ?? row.title, + shortTitle: raw.shortTitle ?? row.shortTitle, + status: raw.status ?? (row.status as GrowActiveMission["status"]), + progressPercent: raw.progressPercent ?? row.progressPercent ?? 0, + currentStageId: raw.currentStageId ?? row.currentStageId ?? undefined, + goal: raw.goal ?? row.goal ?? undefined, + actorType: raw.actorType ?? (row.actorType as GrowActiveMission["actorType"] | undefined), + createdAt: raw.createdAt ?? row.createdAt.getTime(), + updatedAt: raw.updatedAt ?? row.updatedAt.getTime(), + }; +} + +function missionSnapshotFromRow(row: typeof growActiveMissions.$inferSelect): MissionSnapshot | null { + if (!row.snapshot) return null; + const raw = row.snapshot as Partial; + return { + ...(raw as MissionSnapshot), + instanceId: raw.instanceId ?? row.instanceId, + missionId: raw.missionId ?? (row.missionId as MissionSnapshot["missionId"]), + workflowId: raw.workflowId ?? row.workflowId, + userId: raw.userId ?? row.userId, + title: raw.title ?? row.title, + shortTitle: raw.shortTitle ?? row.shortTitle, + status: raw.status ?? (row.status as MissionSnapshot["status"]), + progressPercent: raw.progressPercent ?? row.progressPercent ?? 0, + currentStageId: raw.currentStageId ?? row.currentStageId ?? undefined, + goal: raw.goal ?? row.goal ?? undefined, + stages: raw.stages ?? [], + artifacts: raw.artifacts ?? [], + events: raw.events ?? [], + createdAt: raw.createdAt ?? row.createdAt.toISOString(), + updatedAt: raw.updatedAt ?? row.updatedAt.toISOString(), + }; +} + export async function listActiveMissionsPg(userId: string) { const rows = await db.select().from(growActiveMissions).where(eq(growActiveMissions.userId, userId)).orderBy(desc(growActiveMissions.updatedAt)); - return rows.map((row) => ({ mission: row.mission as unknown as GrowActiveMission, snapshot: row.snapshot as unknown as MissionSnapshot | null })); + return rows.map((row) => ({ mission: activeMissionFromRow(row), snapshot: missionSnapshotFromRow(row) })); } export async function getActiveMissionPg(userId: string, instanceId: string) { const [row] = await db.select().from(growActiveMissions).where(and(eq(growActiveMissions.userId, userId), eq(growActiveMissions.instanceId, instanceId))).limit(1); - return row ? { mission: row.mission as unknown as GrowActiveMission, snapshot: row.snapshot as unknown as MissionSnapshot | null } : null; + return row ? { mission: activeMissionFromRow(row), snapshot: missionSnapshotFromRow(row) } : null; +} + +export async function listMissionSuggestionsPg(userId: string, instanceId: string | undefined | null): Promise { + if (!instanceId) return []; + const rows = await db + .select() + .from(missionSuggestions) + .where(and(eq(missionSuggestions.userId, userId), eq(missionSuggestions.missionInstanceId, instanceId), eq(missionSuggestions.status, "active"))) + .orderBy(desc(missionSuggestions.priority), desc(missionSuggestions.updatedAt)); + return rows.map((row) => ({ + id: row.id, + userId: row.userId, + missionInstanceId: row.missionInstanceId, + missionId: row.missionId, + stageId: row.stageId ?? undefined, + role: row.role, + type: row.type, + title: row.title, + body: row.body, + reason: row.reason ?? undefined, + priority: row.priority, + urgency: row.urgency, + status: row.status, + ctaLabel: row.ctaLabel, + ctaHref: row.ctaHref, + sourceRefs: row.sourceRefs ?? {}, + generatedBy: row.generatedBy, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + expiresAt: row.expiresAt?.toISOString(), + })); +} + +export async function replaceMissionSuggestionsPg(input: { + userId: string; + missionInstanceId: string; + missionId: string; + coachRunId?: string; + suggestions: Array & { id?: string }>; +}) { + const now = new Date(); + await db.update(missionSuggestions) + .set({ status: "expired", updatedAt: now }) + .where(and(eq(missionSuggestions.userId, input.userId), eq(missionSuggestions.missionInstanceId, input.missionInstanceId), eq(missionSuggestions.status, "active"))); + + if (!input.suggestions.length) return []; + await db.insert(missionSuggestions).values(input.suggestions.map((suggestion) => ({ + id: suggestion.id, + userId: input.userId, + missionInstanceId: input.missionInstanceId, + missionId: input.missionId, + stageId: suggestion.stageId, + role: suggestion.role, + type: suggestion.type, + title: suggestion.title, + body: suggestion.body, + reason: suggestion.reason, + priority: suggestion.priority, + urgency: suggestion.urgency, + status: "active" as const, + ctaLabel: suggestion.ctaLabel, + ctaHref: suggestion.ctaHref, + sourceRefs: { ...(suggestion.sourceRefs ?? {}), coachRunId: input.coachRunId }, + generatedBy: suggestion.generatedBy ?? "deterministic", + expiresAt: suggestion.expiresAt ? new Date(suggestion.expiresAt) : undefined, + createdAt: now, + updatedAt: now, + }))); + return listMissionSuggestionsPg(input.userId, input.missionInstanceId); +} + +export async function createMissionCoachRunPg(input: { + userId: string; + missionInstanceId: string; + missionId: string; + windowStart: Date; + windowEnd: Date; + inputDigest: Record; + skillVersion?: string; +}) { + const [row] = await db.insert(missionCoachRuns).values({ + userId: input.userId, + missionInstanceId: input.missionInstanceId, + missionId: input.missionId, + windowStart: input.windowStart, + windowEnd: input.windowEnd, + inputDigest: input.inputDigest, + skillVersion: input.skillVersion, + }).returning(); + if (!row) throw new Error("Failed to create mission coach run"); + return row; +} + +export async function completeMissionCoachRunPg(input: { id: string; summary: string; output: Record }) { + await db.update(missionCoachRuns).set({ + status: "completed", + summary: input.summary, + output: input.output, + completedAt: new Date(), + }).where(eq(missionCoachRuns.id, input.id)); } diff --git a/src/missions/suggestions.ts b/src/missions/suggestions.ts new file mode 100644 index 0000000..7fd2a58 --- /dev/null +++ b/src/missions/suggestions.ts @@ -0,0 +1,152 @@ +import type { MissionSnapshot, MissionStage } from "../actors/missions/types.js"; + +export type MissionSuggestionType = "action" | "practice" | "review" | "artifact" | "blocked" | "insight"; +export type MissionSuggestionUrgency = "now" | "today" | "soon" | "calm"; +export type MissionSuggestionStatus = "active" | "done" | "dismissed" | "expired"; + +export type MissionCoachContext = { + preferences?: Record; +}; + +export type MissionSuggestion = { + id: string; + userId: string; + missionInstanceId: string; + missionId: string; + stageId?: string; + role: string; + type: MissionSuggestionType; + title: string; + body: string; + reason?: string; + priority: number; + urgency: MissionSuggestionUrgency; + status: MissionSuggestionStatus; + ctaLabel: string; + ctaHref: string; + sourceRefs: Record; + generatedBy: "deterministic" | "agent" | "manual"; + createdAt: string; + updatedAt: string; + expiresAt?: string; +}; + +function roleOf(stage: MissionStage) { + const role = stage.role.toLowerCase(); + if (role.includes("resume")) return "Resume"; + if (role.includes("roleplay") || role.includes("communication")) return "Roleplay"; + if (role.includes("q") || role.includes("score") || role.includes("readiness")) return "Q Score"; + if (role.includes("interview")) return "Interview"; + return stage.role || "Mission"; +} + +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 value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +function profileFromContext(context?: MissionCoachContext) { + const preferences = recordOf(context?.preferences); + 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", + jobDescription: typeof interview.job_description === "string" ? interview.job_description : undefined, + focusAreas, + weakSpots: arrayOfStrings(interview.weak_spots), + activeGoal: typeof mission.active_goal === "string" ? mission.active_goal : typeof onboarding.goal === "string" ? onboarding.goal : undefined, + timeline: typeof onboarding.timeline === "string" ? onboarding.timeline : undefined, + }; +} + +function addProfileParams(params: URLSearchParams, profile: ReturnType) { + 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)); +} + +function ctaFor(stage: MissionStage, snapshot: MissionSnapshot, context?: MissionCoachContext) { + const params = new URLSearchParams({ + missionInstanceId: snapshot.instanceId, + missionId: snapshot.missionId, + stageId: stage.id, + source: "mission", + }); + const role = roleOf(stage); + const profile = profileFromContext(context); + addProfileParams(params, profile); + + if (role === "Interview") { + params.set("mode", "mission"); + params.set("prompt", `${stage.title} for ${profile.targetRole}`); + return { label: stage.status === "in_progress" ? "Continue mock" : "Start mock", href: `/agents/interview/setup?${params.toString()}` }; + } + if (role === "Roleplay") { + params.set("scenario", `${stage.title} for ${profile.targetRole}`); + return { label: stage.status === "in_progress" ? "Continue roleplay" : "Start roleplay", href: `/agents/roleplay/setup?${params.toString()}` }; + } + if (role === "Resume") { + params.set("focus", `${stage.title}: ${profile.targetRole}`); + 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()}` }; +} + +function suggestionId(snapshot: MissionSnapshot, stage: MissionStage, suffix: string) { + return `sug-${snapshot.instanceId}-${stage.id}-${suffix}-${Date.now()}`; +} + +export function buildDeterministicMissionSuggestions(snapshot: MissionSnapshot, context: MissionCoachContext = {}) { + const stages = snapshot.stages ?? []; + const active = stages.filter((stage) => stage.status === "blocked" || stage.status === "in_progress" || stage.status === "ready"); + const candidates = active.length ? active : stages.filter((stage) => stage.status !== "done").slice(0, 3); + const expiresAt = new Date(Date.now() + 36 * 60 * 60 * 1000).toISOString(); + const profile = profileFromContext(context); + + return candidates.slice(0, 6).map((stage, index) => { + const role = roleOf(stage); + const cta = ctaFor(stage, snapshot, context); + const isBlocked = stage.status === "blocked"; + const isCurrent = stage.id === snapshot.currentStageId; + const personalizedBody = role === "Interview" + ? `Practice for ${profile.targetRole}${profile.targetCompany !== "target company" ? ` at ${profile.targetCompany}` : ""}. Focus on ${profile.focusAreas.slice(0, 3).join(", ") || "high-leverage interview areas"}.` + : role === "Resume" + ? `Tailor your resume toward ${profile.targetRole}${profile.targetCompany !== "target company" ? ` at ${profile.targetCompany}` : ""}, emphasizing measurable data-science impact.` + : role === "Roleplay" + ? `Roleplay concise stakeholder communication for ${profile.targetRole} interviews, especially ${profile.weakSpots.slice(0, 2).join(" and ") || "tradeoff framing"}.` + : role === "Q Score" + ? `Review readiness signals against your ${profile.targetRole} goal and decide the next highest-leverage action.` + : undefined; + + return { + id: suggestionId(snapshot, stage, index === 0 ? "top" : "next"), + stageId: stage.id, + role, + type: isBlocked ? "blocked" as const : role === "Interview" || role === "Roleplay" ? "practice" as const : "action" as const, + title: isBlocked ? `Unblock ${stage.title}` : stage.status === "in_progress" ? `Continue ${stage.title}` : `Start ${stage.title}`, + body: stage.outputSummary || personalizedBody || stage.description || `Use the ${role} agent to move this mission forward.`, + reason: isCurrent ? (profile.activeGoal ?? "This is the current mission focus.") : "This is the next available step for this workflow.", + priority: (isBlocked ? 130 : isCurrent ? 110 : stage.status === "in_progress" ? 95 : 75) - index, + urgency: isBlocked ? "now" as const : isCurrent ? "today" as const : "soon" as const, + ctaLabel: cta.label, + ctaHref: cta.href, + sourceRefs: { stageId: stage.id, generatedFrom: "mission_snapshot", profile: { targetRole: profile.targetRole, targetCompany: profile.targetCompany } }, + generatedBy: "deterministic" as const, + expiresAt, + }; + }); +} diff --git a/src/routes/missions.ts b/src/routes/missions.ts index c2f4f67..fbea73e 100644 --- a/src/routes/missions.ts +++ b/src/routes/missions.ts @@ -7,7 +7,8 @@ import type { Registry } from "../actors/registry.js"; import type { GrowActiveMission, MissionActorType, MissionSnapshot } from "../actors/missions/types.js"; import { isActorBackedMission } from "../missions/registry.js"; import { getPersistedMissionDefinition, listPersistedMissionDefinitions } from "../missions/postgres-registry.js"; -import { getActiveMissionPg, listActiveMissionsPg, upsertActiveMissionPg } from "../grow/persistence.js"; +import { completeMissionCoachRunPg, createMissionCoachRunPg, getActiveMissionPg, listActiveMissionsPg, listMissionSuggestionsPg, replaceMissionSuggestionsPg, upsertActiveMissionPg } from "../grow/persistence.js"; +import { buildDeterministicMissionSuggestions } from "../missions/suggestions.js"; let _client: Client | null = null; function getClient(): Client { @@ -91,6 +92,18 @@ async function getMissionSnapshot(userId: string, active: GrowActiveMission): Pr return missionActorFor(userId, active.instanceId, active.actorType).getState(); } +async function getUserPreferences(req: Request): Promise> { + 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 user = await res.json().catch(() => null) as Record | null; + const preferences = user?.preferences; + return preferences && typeof preferences === "object" && !Array.isArray(preferences) ? preferences as Record : {}; +} + export function missionRoutes() { const app = new Hono(); app.use("*", requireUser); @@ -109,9 +122,14 @@ export function missionRoutes() { app.get("/active", async (c) => { const userId = c.get("userId"); const persisted = await listActiveMissionsPg(userId); + const suggestionsByMission: Record = {}; + await Promise.all(persisted.map(async (item) => { + suggestionsByMission[item.mission.instanceId] = await listMissionSuggestionsPg(userId, item.mission.instanceId); + })); return c.json({ missions: persisted.map((item) => item.mission), snapshots: persisted.map((item) => item.snapshot).filter((snapshot): snapshot is MissionSnapshot => Boolean(snapshot)), + suggestionsByMission, }); }); @@ -119,7 +137,57 @@ export function missionRoutes() { const userId = c.get("userId"); const active = await getActiveMissionPg(userId, c.req.param("instanceId")); if (!active) return c.json({ error: "mission_not_found" }, 404); - return c.json({ mission: active.mission, snapshot: active.snapshot }); + const suggestions = await listMissionSuggestionsPg(userId, active.mission.instanceId); + return c.json({ mission: active.mission, snapshot: active.snapshot, suggestions }); + }); + + app.post("/active/:instanceId/coach/run", async (c) => { + const userId = c.get("userId"); + const active = await getActiveMissionPg(userId, c.req.param("instanceId")); + if (!active?.snapshot) return c.json({ error: "mission_not_found" }, 404); + + const windowEnd = new Date(); + const windowStart = new Date(windowEnd.getTime() - 24 * 60 * 60 * 1000); + const preferences = await getUserPreferences(c.req.raw); + const run = await createMissionCoachRunPg({ + userId, + missionInstanceId: active.mission.instanceId, + missionId: active.mission.missionId, + windowStart, + windowEnd, + skillVersion: active.snapshot.skillVersion, + inputDigest: { + stageCount: active.snapshot.stages.length, + currentStageId: active.snapshot.currentStageId, + progressPercent: active.snapshot.progressPercent, + artifactCount: active.snapshot.artifacts.length, + eventCount: active.snapshot.events.length, + onboardingCompleted: Boolean((preferences.onboarding as Record | undefined)?.completed_at), + targetRoles: preferences.target_roles, + }, + }); + + const generated = buildDeterministicMissionSuggestions(active.snapshot, { preferences }); + const suggestions = await replaceMissionSuggestionsPg({ + userId, + missionInstanceId: active.mission.instanceId, + missionId: active.mission.missionId, + coachRunId: run.id, + suggestions: generated, + }); + + const summary = suggestions[0] + ? `${suggestions.length} mission suggestion${suggestions.length === 1 ? "" : "s"} refreshed. Top action: ${suggestions[0].title}` + : "Mission suggestions refreshed. No active action is required right now."; + await completeMissionCoachRunPg({ id: run.id, summary, output: { suggestions } }); + if (active.mission.actorType) { + await missionActorFor(userId, active.mission.instanceId, active.mission.actorType).recordEvent({ + type: "mission.nightly_review.completed", + message: summary, + payload: { coachRunId: run.id, suggestionIds: suggestions.map((s) => s.id), triggeredBy: "manual-refresh" }, + }); + } + return c.json({ coachRunId: run.id, summary, suggestions }); }); app.post("/:missionId/start", async (c) => { diff --git a/src/routes/services.ts b/src/routes/services.ts index 0fa1ee1..867de74 100644 --- a/src/routes/services.ts +++ b/src/routes/services.ts @@ -5,7 +5,7 @@ import { config } from "../config.js"; import { listServiceCapabilities } from "../workflows/service-capabilities.js"; import { interviewService, resumeService, roleplayService, type JsonObject } from "../services/product-service-clients.js"; import { db } from "../db/client.js"; -import { events } from "../db/schema.js"; +import { events, growQscoreLatest, growQscoreProjectionState } from "../db/schema.js"; import { recordGrowEvent } from "../events/record-grow-event.js"; import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js"; import { log } from "../log.js"; @@ -118,6 +118,30 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) { }); } +async function proxySocialRequest(req: Request, rest: string, userId: string) { + const incoming = new URL(req.url); + const normalizedRest = rest.replace(/^\/+/, ""); + const target = new URL( + `/api/v1/${normalizedRest}${incoming.search}`, + config.socialBrandingServiceUrl.replace(/\/$/, ""), + ); + + const headers = new Headers(req.headers); + headers.delete("host"); + headers.delete("cookie"); + headers.set("x-growqr-user", userId); + + const method = req.method.toUpperCase(); + const body = ["GET", "HEAD"].includes(method) ? undefined : await req.arrayBuffer(); + const res = await fetch(target, { method, headers, body }); + + return new Response(res.body, { + status: res.status, + statusText: res.statusText, + headers: res.headers, + }); +} + export function serviceRoutes() { const app = new Hono(); app.use("*", requireUser); @@ -161,9 +185,73 @@ export function serviceRoutes() { if (service === "interview") return c.json(await interviewService.health()); if (service === "roleplay") return c.json(await roleplayService.health()); if (service === "resume") return c.json(await resumeService.health()); + if (service === "social") { + const res = await fetch(new URL("/health", config.socialBrandingServiceUrl.replace(/\/$/, ""))); + return c.json(await res.json(), res.status as never); + } return c.json({ error: "unknown_service" }, 404); }); + app.get("/qscore/current", async (c) => { + const userId = c.get("userId"); + const [projection] = await db + .select() + .from(growQscoreProjectionState) + .where(eq(growQscoreProjectionState.userId, userId)) + .limit(1); + const signals = await db + .select() + .from(growQscoreLatest) + .where(eq(growQscoreLatest.userId, userId)) + .orderBy(desc(growQscoreLatest.updatedAt)); + + const grouped = new Map }>(); + for (const signal of signals) { + const groupId = signal.signalId.split(".")[0] || "readiness"; + const current = grouped.get(groupId) ?? { + label: groupId.replace(/-/g, " ").replace(/^./, (char) => char.toUpperCase()), + score: 0, + count: 0, + sources: new Set(), + }; + current.score += signal.score; + current.count += 1; + if (signal.source) current.sources.add(signal.source); + grouped.set(groupId, current); + } + + const dimensions = Array.from(grouped.entries()).map(([id, group]) => ({ + id, + label: group.label, + score: Math.round(group.score / Math.max(group.count, 1)), + signalCount: group.count, + sources: Array.from(group.sources), + })); + const score = projection?.score && projection.score > 0 + ? projection.score + : signals.length + ? Math.round(signals.reduce((sum, signal) => sum + signal.score, 0) / signals.length) + : null; + + return c.json({ + qscore: score === null ? null : { + score, + signalCount: projection?.signalCount ?? signals.length, + summary: projection?.summary ?? `Readiness score computed from ${signals.length} current signal${signals.length === 1 ? "" : "s"}.`, + dimensions, + updatedAt: projection?.updatedAt?.toISOString() ?? signals[0]?.updatedAt?.toISOString() ?? null, + }, + signals: signals.map((signal) => ({ + signalId: signal.signalId, + score: Math.round(signal.score), + present: signal.present, + source: signal.source, + occurredAt: signal.occurredAt.toISOString(), + updatedAt: signal.updatedAt.toISOString(), + })), + }); + }); + app.get("/interview/page-state", async (c) => c.json(await interviewService.pageState(c.get("userId")))); app.post("/interview/configure", async (c) => { const userId = c.get("userId"); @@ -280,5 +368,11 @@ export function serviceRoutes() { return proxyResumeRequest(c.req.raw, rest, c.get("userId")); }); + // Social branding proxy used by onboarding LinkedIn fetch and future profile UX. + app.all("/social/*", async (c) => { + const rest = c.req.path.split("/social/")[1] ?? ""; + return proxySocialRequest(c.req.raw, rest, c.get("userId")); + }); + return app; } diff --git a/src/routes/users.ts b/src/routes/users.ts index dce8946..cd02169 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -5,22 +5,68 @@ import { users, userStacks, type UserStack } from "../db/schema.js"; import { eq } from "drizzle-orm"; import { provisionUserStack } from "../docker/manager.js"; import { log } from "../log.js"; +import { config } from "../config.js"; + function publicStack(stack: UserStack | null | undefined) { if (!stack) return stack; const { opencodePassword: _opencodePassword, ...safe } = stack; return safe; } +function userServiceTarget(path: string, search = "") { + return new URL(`/api/v1/users${path}${search}`, config.userServiceUrl.replace(/\/$/, "")); +} + +async function proxyUserService(req: Request, path: string) { + const incoming = new URL(req.url); + const target = userServiceTarget(path, incoming.search); + const headers = new Headers(req.headers); + headers.delete("host"); + headers.delete("cookie"); + + const method = req.method.toUpperCase(); + const body = ["GET", "HEAD"].includes(method) ? undefined : await req.arrayBuffer(); + const res = await fetch(target, { method, headers, body }); + return new Response(res.body, { + status: res.status, + statusText: res.statusText, + headers: res.headers, + }); +} + +async function ensureUserServiceUser(req: Request) { + const target = userServiceTarget("/ensure"); + const headers = new Headers(req.headers); + headers.delete("host"); + headers.delete("cookie"); + const res = await fetch(target, { method: "POST", headers }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`user-service ensure failed: ${res.status} ${text}`); + } + return res.json() as Promise>; +} + export function userRoutes() { const app = new Hono(); app.use("*", requireUser); // Called by the frontend right after Clerk sign-in. - // - Ensures a `users` row exists (the auth middleware already lazy-mirrors). - // - Kicks off Grow Agent stack provisioning if not already running. - // - Returns the current stack status so the UI can render a provisioning spinner. + // Ensures both the GrowQR backend mirror row and the production user-service + // row exist. Onboarding preferences are stored by user-service. app.post("/bootstrap", async (c) => { const userId = c.get("userId"); + let userServiceUser: Record; + try { + userServiceUser = await ensureUserServiceUser(c.req.raw); + } catch (err) { + log.error({ err, userId }, "failed to bootstrap user-service user"); + return c.json( + { error: "user_service_bootstrap_failed", message: "Failed to create or load the user-service profile" }, + 502, + ); + } + const userRow = await db.query.users.findFirst({ where: eq(users.id, userId), }); @@ -29,29 +75,25 @@ export function userRoutes() { }); if (!stack || stack.status !== "running") { - // Fire-and-forget; the frontend polls /users/me until status === running. void provisionUserStack(userId).catch((err) => log.error({ err, userId }, "background provision failed"), ); } return c.json({ - user: userRow, + user: userServiceUser, + backendUser: userRow, stack: publicStack(stack) ?? { status: "provisioning" }, grow: null, }); }); - app.get("/me", async (c) => { - const userId = c.get("userId"); - const userRow = await db.query.users.findFirst({ - where: eq(users.id, userId), - }); - const stack = await db.query.userStacks.findFirst({ - where: eq(userStacks.userId, userId), - }); - return c.json({ user: userRow, stack: publicStack(stack) }); - }); + app.get("/me", async (c) => proxyUserService(c.req.raw, "/me")); + app.patch("/me", async (c) => proxyUserService(c.req.raw, "/me")); + app.get("/me/plan", async (c) => proxyUserService(c.req.raw, "/me/plan")); + app.post("/me/photo", async (c) => proxyUserService(c.req.raw, "/me/photo")); + app.delete("/me/photo", async (c) => proxyUserService(c.req.raw, "/me/photo")); + app.get("/me/qr-code", async (c) => proxyUserService(c.req.raw, "/me/qr-code")); return app; }