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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
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 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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user