integrated phase 1
This commit is contained in:
47
drizzle/0009_mission_suggestions.sql
Normal file
47
drizzle/0009_mission_suggestions.sql
Normal 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");
|
||||
@@ -64,6 +64,13 @@
|
||||
"when": 1780481300000,
|
||||
"tag": "0008_home_notifications",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1780481400000,
|
||||
"tag": "0009_mission_suggestions",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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<AuthContext>(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 })
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<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(
|
||||
"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;
|
||||
|
||||
@@ -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<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) {
|
||||
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<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
152
src/missions/suggestions.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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<Registry> | null = null;
|
||||
function getClient(): Client<Registry> {
|
||||
@@ -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<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() {
|
||||
const app = new Hono<AuthContext>();
|
||||
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<string, unknown[]> = {};
|
||||
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<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) => {
|
||||
|
||||
@@ -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<AuthContext>();
|
||||
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<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.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;
|
||||
}
|
||||
|
||||
@@ -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<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export function userRoutes() {
|
||||
const app = new Hono<AuthContext>();
|
||||
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<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({
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user