integrated phase 1

This commit is contained in:
-Puter
2026-06-05 00:40:28 +05:30
parent 9e96912942
commit e478db9334
10 changed files with 648 additions and 30 deletions

View File

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

View File

@@ -64,6 +64,13 @@
"when": 1780481300000, "when": 1780481300000,
"tag": "0008_home_notifications", "tag": "0008_home_notifications",
"breakpoints": true "breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1780481400000,
"tag": "0009_mission_suggestions",
"breakpoints": true
} }
] ]
} }

View File

@@ -5,6 +5,7 @@ import { config } from "../config.js";
import { db } from "../db/client.js"; import { db } from "../db/client.js";
import { users } from "../db/schema.js"; import { users } from "../db/schema.js";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { log } from "../log.js";
export type AuthContext = { export type AuthContext = {
Variables: { Variables: {
@@ -86,15 +87,21 @@ export const requireUser = createMiddleware<AuthContext>(async (c, next) => {
// Lazy-mirror Clerk user → users table. // Lazy-mirror Clerk user → users table.
let row = await db.query.users.findFirst({ where: eq(users.id, userId) }); let row = await db.query.users.findFirst({ where: eq(users.id, userId) });
if (!row) { if (!row) {
const clerkUser = await clerk.users.getUser(userId); let email = `${userId}@unknown.local`;
const email = let displayName: string | null = null;
clerkUser.primaryEmailAddress?.emailAddress ?? try {
clerkUser.emailAddresses[0]?.emailAddress ?? const clerkUser = await clerk.users.getUser(userId);
`${userId}@unknown.local`; email =
const displayName = clerkUser.primaryEmailAddress?.emailAddress ??
[clerkUser.firstName, clerkUser.lastName].filter(Boolean).join(" ") || clerkUser.emailAddresses[0]?.emailAddress ??
clerkUser.username || email;
null; 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 const inserted = await db
.insert(users) .insert(users)
.values({ id: userId, email, displayName }) .values({ id: userId, email, displayName })

View File

@@ -73,6 +73,8 @@ export const config = {
process.env.QSCORE_SERVICE_URL ?? "http://localhost:8000", process.env.QSCORE_SERVICE_URL ?? "http://localhost:8000",
resumeServiceUrl: resumeServiceUrl:
process.env.RESUME_SERVICE_URL ?? "http://localhost:8002", process.env.RESUME_SERVICE_URL ?? "http://localhost:8002",
userServiceUrl:
process.env.USER_SERVICE_URL ?? "http://localhost:8003",
resumePublicUrl: resumePublicUrl:
process.env.RESUME_PUBLIC_URL ?? process.env.RESUME_SERVICE_URL ?? "http://localhost:8002", process.env.RESUME_PUBLIC_URL ?? process.env.RESUME_SERVICE_URL ?? "http://localhost:8002",
matchmakingServiceUrl: matchmakingServiceUrl:

View File

@@ -458,6 +458,60 @@ export const growQscoreProjectionState = pgTable("grow_qscore_projection_state",
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), 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<Record<string, unknown>>().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<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
output: jsonb("output").$type<Record<string, unknown>>().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( export const growHomeNotifications = pgTable(
"grow_home_notifications", "grow_home_notifications",
{ {
@@ -490,5 +544,8 @@ export const growHomeNotifications = pgTable(
export type GrowEventRow = typeof growEvents.$inferSelect; export type GrowEventRow = typeof growEvents.$inferSelect;
export type NewGrowEvent = typeof growEvents.$inferInsert; 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 GrowHomeNotificationRow = typeof growHomeNotifications.$inferSelect;
export type NewGrowHomeNotification = typeof growHomeNotifications.$inferInsert; export type NewGrowHomeNotification = typeof growHomeNotifications.$inferInsert;

View File

@@ -1,7 +1,8 @@
import { asc, desc, eq, and } from "drizzle-orm"; import { asc, desc, eq, and } from "drizzle-orm";
import { db } from "../db/client.js"; 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 { GrowActiveMission, MissionSnapshot } from "../actors/missions/types.js";
import type { MissionSuggestion } from "../missions/suggestions.js";
import type { ConversationMessage } from "../actors/conversation/types.js"; import type { ConversationMessage } from "../actors/conversation/types.js";
import type { GrowConversation } from "../actors/grow/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<GrowActiveMission>;
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<MissionSnapshot>;
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) { export async function listActiveMissionsPg(userId: string) {
const rows = await db.select().from(growActiveMissions).where(eq(growActiveMissions.userId, userId)).orderBy(desc(growActiveMissions.updatedAt)); 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) { 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); 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<MissionSuggestion[]> {
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<Omit<MissionSuggestion, "id" | "userId" | "missionInstanceId" | "missionId" | "status" | "createdAt" | "updatedAt"> & { 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<string, unknown>;
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<string, unknown> }) {
await db.update(missionCoachRuns).set({
status: "completed",
summary: input.summary,
output: input.output,
completedAt: new Date(),
}).where(eq(missionCoachRuns.id, input.id));
} }

152
src/missions/suggestions.ts Normal file
View File

@@ -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<string, unknown>;
};
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<string, unknown>;
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<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
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<typeof profileFromContext>) {
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,
};
});
}

View File

@@ -7,7 +7,8 @@ import type { Registry } from "../actors/registry.js";
import type { GrowActiveMission, MissionActorType, MissionSnapshot } from "../actors/missions/types.js"; import type { GrowActiveMission, MissionActorType, MissionSnapshot } from "../actors/missions/types.js";
import { isActorBackedMission } from "../missions/registry.js"; import { isActorBackedMission } from "../missions/registry.js";
import { getPersistedMissionDefinition, listPersistedMissionDefinitions } from "../missions/postgres-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<Registry> | null = null; let _client: Client<Registry> | null = null;
function getClient(): Client<Registry> { function getClient(): Client<Registry> {
@@ -91,6 +92,18 @@ async function getMissionSnapshot(userId: string, active: GrowActiveMission): Pr
return missionActorFor(userId, active.instanceId, active.actorType).getState(); return missionActorFor(userId, active.instanceId, active.actorType).getState();
} }
async function getUserPreferences(req: Request): Promise<Record<string, unknown>> {
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<string, unknown> | null;
const preferences = user?.preferences;
return preferences && typeof preferences === "object" && !Array.isArray(preferences) ? preferences as Record<string, unknown> : {};
}
export function missionRoutes() { export function missionRoutes() {
const app = new Hono<AuthContext>(); const app = new Hono<AuthContext>();
app.use("*", requireUser); app.use("*", requireUser);
@@ -109,9 +122,14 @@ export function missionRoutes() {
app.get("/active", async (c) => { app.get("/active", async (c) => {
const userId = c.get("userId"); const userId = c.get("userId");
const persisted = await listActiveMissionsPg(userId); const persisted = await listActiveMissionsPg(userId);
const suggestionsByMission: Record<string, unknown[]> = {};
await Promise.all(persisted.map(async (item) => {
suggestionsByMission[item.mission.instanceId] = await listMissionSuggestionsPg(userId, item.mission.instanceId);
}));
return c.json({ return c.json({
missions: persisted.map((item) => item.mission), missions: persisted.map((item) => item.mission),
snapshots: persisted.map((item) => item.snapshot).filter((snapshot): snapshot is MissionSnapshot => Boolean(snapshot)), 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 userId = c.get("userId");
const active = await getActiveMissionPg(userId, c.req.param("instanceId")); const active = await getActiveMissionPg(userId, c.req.param("instanceId"));
if (!active) return c.json({ error: "mission_not_found" }, 404); 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<string, unknown> | 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) => { app.post("/:missionId/start", async (c) => {

View File

@@ -5,7 +5,7 @@ import { config } from "../config.js";
import { listServiceCapabilities } from "../workflows/service-capabilities.js"; import { listServiceCapabilities } from "../workflows/service-capabilities.js";
import { interviewService, resumeService, roleplayService, type JsonObject } from "../services/product-service-clients.js"; import { interviewService, resumeService, roleplayService, type JsonObject } from "../services/product-service-clients.js";
import { db } from "../db/client.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 { recordGrowEvent } from "../events/record-grow-event.js";
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js"; import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
import { log } from "../log.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() { export function serviceRoutes() {
const app = new Hono<AuthContext>(); const app = new Hono<AuthContext>();
app.use("*", requireUser); app.use("*", requireUser);
@@ -161,9 +185,73 @@ export function serviceRoutes() {
if (service === "interview") return c.json(await interviewService.health()); if (service === "interview") return c.json(await interviewService.health());
if (service === "roleplay") return c.json(await roleplayService.health()); if (service === "roleplay") return c.json(await roleplayService.health());
if (service === "resume") return c.json(await resumeService.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); 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<string, { label: string; score: number; count: number; sources: Set<string> }>();
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<string>(),
};
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.get("/interview/page-state", async (c) => c.json(await interviewService.pageState(c.get("userId"))));
app.post("/interview/configure", async (c) => { app.post("/interview/configure", async (c) => {
const userId = c.get("userId"); const userId = c.get("userId");
@@ -280,5 +368,11 @@ export function serviceRoutes() {
return proxyResumeRequest(c.req.raw, rest, c.get("userId")); 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; return app;
} }

View File

@@ -5,22 +5,68 @@ import { users, userStacks, type UserStack } from "../db/schema.js";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { provisionUserStack } from "../docker/manager.js"; import { provisionUserStack } from "../docker/manager.js";
import { log } from "../log.js"; import { log } from "../log.js";
import { config } from "../config.js";
function publicStack(stack: UserStack | null | undefined) { function publicStack(stack: UserStack | null | undefined) {
if (!stack) return stack; if (!stack) return stack;
const { opencodePassword: _opencodePassword, ...safe } = stack; const { opencodePassword: _opencodePassword, ...safe } = stack;
return safe; 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<Record<string, unknown>>;
}
export function userRoutes() { export function userRoutes() {
const app = new Hono<AuthContext>(); const app = new Hono<AuthContext>();
app.use("*", requireUser); app.use("*", requireUser);
// Called by the frontend right after Clerk sign-in. // Called by the frontend right after Clerk sign-in.
// - Ensures a `users` row exists (the auth middleware already lazy-mirrors). // Ensures both the GrowQR backend mirror row and the production user-service
// - Kicks off Grow Agent stack provisioning if not already running. // row exist. Onboarding preferences are stored by user-service.
// - Returns the current stack status so the UI can render a provisioning spinner.
app.post("/bootstrap", async (c) => { app.post("/bootstrap", async (c) => {
const userId = c.get("userId"); const userId = c.get("userId");
let userServiceUser: Record<string, unknown>;
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({ const userRow = await db.query.users.findFirst({
where: eq(users.id, userId), where: eq(users.id, userId),
}); });
@@ -29,29 +75,25 @@ export function userRoutes() {
}); });
if (!stack || stack.status !== "running") { if (!stack || stack.status !== "running") {
// Fire-and-forget; the frontend polls /users/me until status === running.
void provisionUserStack(userId).catch((err) => void provisionUserStack(userId).catch((err) =>
log.error({ err, userId }, "background provision failed"), log.error({ err, userId }, "background provision failed"),
); );
} }
return c.json({ return c.json({
user: userRow, user: userServiceUser,
backendUser: userRow,
stack: publicStack(stack) ?? { status: "provisioning" }, stack: publicStack(stack) ?? { status: "provisioning" },
grow: null, grow: null,
}); });
}); });
app.get("/me", async (c) => { app.get("/me", async (c) => proxyUserService(c.req.raw, "/me"));
const userId = c.get("userId"); app.patch("/me", async (c) => proxyUserService(c.req.raw, "/me"));
const userRow = await db.query.users.findFirst({ app.get("/me/plan", async (c) => proxyUserService(c.req.raw, "/me/plan"));
where: eq(users.id, userId), app.post("/me/photo", async (c) => proxyUserService(c.req.raw, "/me/photo"));
}); app.delete("/me/photo", async (c) => proxyUserService(c.req.raw, "/me/photo"));
const stack = await db.query.userStacks.findFirst({ app.get("/me/qr-code", async (c) => proxyUserService(c.req.raw, "/me/qr-code"));
where: eq(userStacks.userId, userId),
});
return c.json({ user: userRow, stack: publicStack(stack) });
});
return app; return app;
} }