This commit is contained in:
-Puter
2026-06-04 21:36:58 +05:30
parent 1d3cfbcff7
commit 9e96912942
11 changed files with 1034 additions and 30 deletions
+36
View File
@@ -0,0 +1,36 @@
CREATE TABLE IF NOT EXISTS grow_home_notifications (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
module_id TEXT NOT NULL,
title TEXT NOT NULL,
subtitle TEXT NOT NULL,
tag TEXT NOT NULL,
urgency TEXT NOT NULL DEFAULT 'calm',
href TEXT NOT NULL,
source TEXT,
source_ref JSONB,
priority INTEGER NOT NULL DEFAULT 0,
generated_by TEXT NOT NULL DEFAULT 'deterministic',
reason TEXT,
status TEXT NOT NULL DEFAULT 'active',
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT grow_home_notifications_module_check
CHECK (module_id IN ('suggestions', 'missions', 'social', 'pathways', 'productivity', 'rewards')),
CONSTRAINT grow_home_notifications_urgency_check
CHECK (urgency IN ('now', 'today', 'soon', 'calm')),
CONSTRAINT grow_home_notifications_generated_by_check
CHECK (generated_by IN ('deterministic', 'agent', 'demo', 'manual')),
CONSTRAINT grow_home_notifications_status_check
CHECK (status IN ('active', 'dismissed', 'expired'))
);
CREATE INDEX IF NOT EXISTS grow_home_notifications_user_idx
ON grow_home_notifications(user_id, status, priority DESC);
CREATE INDEX IF NOT EXISTS grow_home_notifications_module_idx
ON grow_home_notifications(user_id, module_id, status);
CREATE INDEX IF NOT EXISTS grow_home_notifications_expiry_idx
ON grow_home_notifications(expires_at);
+7
View File
@@ -57,6 +57,13 @@
"when": 1780481200000,
"tag": "0007_grow_event_backbone",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1780481300000,
"tag": "0008_home_notifications",
"breakpoints": true
}
]
}
+8 -2
View File
@@ -27,10 +27,16 @@ export const requireUser = createMiddleware<AuthContext>(async (c, next) => {
// Service-to-service path (Grow Agent actor calling backend).
// Header `x-growqr-user` is REQUIRED so we can scope the call.
const trustedServiceTokens = new Set(
[
config.serviceToken,
config.nodeEnv !== "production" ? config.a2aAllowedKey : "",
].filter(Boolean),
);
if (
token &&
config.serviceToken &&
token === config.serviceToken &&
trustedServiceTokens.has(token) &&
c.req.header("x-growqr-user")
) {
const userId = c.req.header("x-growqr-user")!;
+32
View File
@@ -458,5 +458,37 @@ export const growQscoreProjectionState = pgTable("grow_qscore_projection_state",
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});
export const growHomeNotifications = pgTable(
"grow_home_notifications",
{
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
moduleId: text("module_id", {
enum: ["suggestions", "missions", "social", "pathways", "productivity", "rewards"],
}).notNull(),
title: text("title").notNull(),
subtitle: text("subtitle").notNull(),
tag: text("tag").notNull(),
urgency: text("urgency", { enum: ["now", "today", "soon", "calm"] }).notNull().default("calm"),
href: text("href").notNull(),
source: text("source"),
sourceRef: jsonb("source_ref").$type<Record<string, unknown>>(),
priority: integer("priority").notNull().default(0),
generatedBy: text("generated_by", { enum: ["deterministic", "agent", "demo", "manual"] }).notNull().default("deterministic"),
reason: text("reason"),
status: text("status", { enum: ["active", "dismissed", "expired"] }).notNull().default("active"),
expiresAt: timestamp("expires_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
userIdx: index("grow_home_notifications_user_idx").on(t.userId, t.status, t.priority),
moduleIdx: index("grow_home_notifications_module_idx").on(t.userId, t.moduleId, t.status),
expiryIdx: index("grow_home_notifications_expiry_idx").on(t.expiresAt),
}),
);
export type GrowEventRow = typeof growEvents.$inferSelect;
export type NewGrowEvent = typeof growEvents.$inferInsert;
export type GrowHomeNotificationRow = typeof growHomeNotifications.$inferSelect;
export type NewGrowHomeNotification = typeof growHomeNotifications.$inferInsert;
+91
View File
@@ -0,0 +1,91 @@
import { Output, generateText } from "ai";
import { z } from "zod";
import { getConversationModel } from "../actors/conversation/agent.js";
import { config } from "../config.js";
import { log } from "../log.js";
import { ALLOWED_NOTIFICATION_HREFS, MODULE_IDS, type HomeModuleId, type HomeNotification, type HomeUrgency } from "./types.js";
const notificationSchema = z.object({
moduleId: z.enum(MODULE_IDS as [HomeModuleId, ...HomeModuleId[]]),
title: z.string().min(4).max(72),
subtitle: z.string().min(4).max(110),
tag: z.string().min(2).max(14),
urgency: z.enum(["now", "today", "soon", "calm"]),
href: z.string().min(1),
source: z.enum(["resume", "interview", "roleplay", "qscore", "mission", "social", "pathways", "rewards", "system"]),
reason: z.string().max(160).optional(),
});
const feedSchema = z.object({
notifications: z.array(notificationSchema).min(6).max(24),
});
export type AgentHomeNotification = z.infer<typeof notificationSchema>;
const SYSTEM = `You are GrowQR's Home Feed Agent.
Your job is to rank and rewrite dashboard notifications from real platform context.
Keep them coherent, specific, and action-oriented. Do not invent unavailable products, scores, sessions, deadlines, companies, artifacts, or rewards.
Every notification must point to one of these real dashboard routes:
- /agents/resume for resume building, resume analysis, ATS, resume suggestions
- /agents/interview for mock interview setup, interview session, interview review
- /agents/roleplay for recruiter/manager/salary/stakeholder roleplay
- /agents/qscore for Q Score/readiness explanations
- /missions for mission progress, approvals, artifacts, next stages
- /social for LinkedIn/social branding
- /pathways for locked/coming-soon pathways
- /rewards for locked/coming-soon rewards
- /suggestions for broad onboarding/profile suggestions
Use minimal iPhone-notification copy: title <= 72 chars, subtitle <= 110 chars, short tag <= 14 chars.
Use urgency truthfully: now = needs immediate user action, today = useful today, soon = next few days, calm = informational.`;
function sanitizeHref(href: string, moduleId: HomeModuleId) {
if (ALLOWED_NOTIFICATION_HREFS.has(href)) return href;
if (href.startsWith("/agents/resume")) return "/agents/resume";
if (href.startsWith("/agents/interview")) return "/agents/interview";
if (href.startsWith("/agents/roleplay")) return "/agents/roleplay";
if (href.startsWith("/agents/qscore")) return "/agents/qscore";
if (href.startsWith("/missions")) return "/missions";
if (href.startsWith("/social")) return "/social";
if (href.startsWith("/pathways")) return "/pathways";
if (href.startsWith("/rewards")) return "/rewards";
if (href.startsWith("/productivity")) return "/productivity";
return moduleId === "productivity" ? "/productivity" : `/${moduleId}`;
}
function stableId(prefix: string, index: number) {
return `${prefix}-${index + 1}`;
}
export async function refineHomeNotificationsWithAgent(input: {
userId: string;
context: Record<string, unknown>;
seeds: Array<Omit<HomeNotification, "id" | "createdAt"> & { moduleId: HomeModuleId }>;
}): Promise<Array<AgentHomeNotification & { id: string; createdAt: string }>> {
if (!config.llmApiKey) return [];
try {
const result = await generateText({
model: getConversationModel(),
output: Output.object({ schema: feedSchema }),
system: SYSTEM,
prompt: JSON.stringify({
task: "Create coherent GrowQR home dashboard notifications from the provided service context and deterministic candidates.",
userId: input.userId,
serviceContext: input.context,
deterministicCandidates: input.seeds,
}),
});
const now = new Date().toISOString();
return result.output.notifications.map((n, index) => ({
...n,
href: sanitizeHref(n.href, n.moduleId),
urgency: n.urgency as HomeUrgency,
id: stableId("agent-home", index),
createdAt: now,
}));
} catch (err) {
log.warn({ err, userId: input.userId }, "home feed agent failed; using deterministic notifications");
return [];
}
}
+507
View File
@@ -0,0 +1,507 @@
import { and, desc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm";
import { db } from "../db/client.js";
import {
growActiveMissions,
growEvents,
growHomeNotifications,
growQscoreLatest,
growQscoreProjectionState,
missionArtifacts,
missionServiceSessions,
qscoreSnapshots,
users,
type GrowHomeNotificationRow,
type NewGrowHomeNotification,
} from "../db/schema.js";
import { interviewService, resumeService, roleplayService } from "../services/product-service-clients.js";
import { refineHomeNotificationsWithAgent } from "./home-feed-agent.js";
import {
ALLOWED_NOTIFICATION_HREFS,
MODULE_IDS,
MODULE_META,
type HomeFeedResponse,
type HomeModule,
type HomeModuleId,
type HomeNotification,
type HomeSource,
type HomeUrgency,
} from "./types.js";
const FRESH_MS = 10 * 60 * 1000;
const EXPIRY_MS = 24 * 60 * 60 * 1000;
const SERVICE_HREFS = {
resume: "/agents/resume",
interview: "/agents/interview",
roleplay: "/agents/roleplay",
qscore: "/agents/qscore",
mission: "/missions",
social: "/social",
pathways: "/pathways",
rewards: "/rewards",
suggestions: "/suggestions",
productivity: "/productivity",
} as const;
type SeedNotification = Omit<HomeNotification, "id" | "createdAt"> & { moduleId: HomeModuleId; priority: number };
type HomeContext = {
user: { id: string; email: string; displayName: string | null } | undefined;
qscore: { score: number; signalCount: number; summary: string | null; dimensions: Record<string, unknown> | null } | undefined;
qscoreSignals: Array<{ signalId: string; score: number; source: string | null; updatedAt: Date }>;
activeMissions: Array<{ instanceId: string; missionId: string; title: string; status: string; progressPercent: number; currentStageId: string | null; updatedAt: Date }>;
sessions: Array<{ serviceId: string; externalId: string; status: string; updatedAt: Date; metadata: Record<string, unknown> | null }>;
artifacts: Array<{ serviceId: string | null; type: string; title: string; status: string; summary: string | null; createdAt: Date }>;
events: Array<{ source: string; type: string; occurredAt: Date; payload: Record<string, unknown> }>;
serviceStates: Record<string, unknown>;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function numberFrom(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function sanitizeUrgency(value: string): HomeUrgency {
if (value === "now" || value === "today" || value === "soon" || value === "calm") return value;
return "calm";
}
function sanitizeHref(href: string | undefined, fallback: string) {
if (href && ALLOWED_NOTIFICATION_HREFS.has(href)) return href;
return fallback;
}
function pushSeed(seeds: SeedNotification[], seed: SeedNotification) {
seeds.push({ ...seed, href: sanitizeHref(seed.href, MODULE_META[seed.moduleId].href) });
}
function latestScore(signals: HomeContext["qscoreSignals"], signalId: string) {
return signals.find((s) => s.signalId === signalId)?.score;
}
function serviceSession(ctx: HomeContext, serviceId: string) {
return ctx.sessions.find((s) => s.serviceId === serviceId);
}
function serviceEvent(ctx: HomeContext, prefix: string, includes?: string) {
return ctx.events.find((e) => e.type.startsWith(prefix) && (!includes || e.type.includes(includes)));
}
function hasAnyRealActivity(ctx: HomeContext) {
return Boolean(
ctx.qscore?.signalCount ||
ctx.qscoreSignals.length ||
ctx.activeMissions.length ||
ctx.sessions.length ||
ctx.artifacts.length ||
ctx.events.length,
);
}
function buildDayOneSeeds(): SeedNotification[] {
const seeds: SeedNotification[] = [];
pushSeed(seeds, { moduleId: "suggestions", title: "Start with your Q Score", subtitle: "A quick readiness scan calibrates resume, interview, and roleplay tips.", tag: "Start", urgency: "now", href: SERVICE_HREFS.qscore, source: "qscore", priority: 90 });
pushSeed(seeds, { moduleId: "suggestions", title: "Add your target role", subtitle: "One role goal makes every recommendation sharper.", tag: "Profile", urgency: "today", href: SERVICE_HREFS.suggestions, source: "system", priority: 80 });
pushSeed(seeds, { moduleId: "missions", title: "Explore Interview-to-Offer", subtitle: "A guided mission connects resume fit, mock practice, and readiness scoring.", tag: "Browse", urgency: "today", href: SERVICE_HREFS.mission, source: "mission", priority: 80 });
pushSeed(seeds, { moduleId: "missions", title: "No approvals pending yet", subtitle: "Start a mission and this tile will track missing steps and progress.", tag: "Quiet", urgency: "calm", href: SERVICE_HREFS.mission, source: "mission", priority: 55 });
pushSeed(seeds, { moduleId: "social", title: "Connect LinkedIn when ready", subtitle: "Social branding recommendations unlock after your profile is available.", tag: "Setup", urgency: "soon", href: SERVICE_HREFS.social, source: "social", priority: 60 });
pushSeed(seeds, { moduleId: "social", title: "Build proof before posting", subtitle: "Resume and mock interview artifacts can become stronger featured pins.", tag: "Proof", urgency: "calm", href: SERVICE_HREFS.social, source: "social", priority: 50 });
pushSeed(seeds, { moduleId: "pathways", title: "Pathways are warming up", subtitle: "Complete resume + interview activity to unlock better route recommendations.", tag: "Soon", urgency: "calm", href: SERVICE_HREFS.pathways, source: "pathways", priority: 40 });
pushSeed(seeds, { moduleId: "productivity", title: "Open Resume Builder", subtitle: "Upload or create a resume to generate ATS and content recommendations.", tag: "Resume", urgency: "now", href: SERVICE_HREFS.resume, source: "resume", priority: 85 });
pushSeed(seeds, { moduleId: "productivity", title: "Try a 10-minute mock interview", subtitle: "The interview service creates a role-aware live practice session.", tag: "Mock", urgency: "soon", href: SERVICE_HREFS.interview, source: "interview", priority: 70 });
pushSeed(seeds, { moduleId: "productivity", title: "Roleplay is available for pressure practice", subtitle: "Use it for recruiter screens, salary asks, or manager conversations.", tag: "Roleplay", urgency: "calm", href: SERVICE_HREFS.roleplay, source: "roleplay", priority: 55 });
pushSeed(seeds, { moduleId: "rewards", title: "Rewards unlock after activity", subtitle: "Finish readiness actions to start earning demo streaks and perks.", tag: "Locked", urgency: "calm", href: SERVICE_HREFS.rewards, source: "rewards", priority: 35 });
return seeds;
}
function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
const seeds = buildDayOneSeeds().filter((seed) => seed.moduleId === "pathways" || seed.moduleId === "rewards");
const qscore = ctx.qscore?.score ?? Math.round(ctx.qscoreSignals.reduce((sum, s) => sum + s.score, 0) / Math.max(ctx.qscoreSignals.length, 1));
const ats = latestScore(ctx.qscoreSignals, "resume.ats_compatibility");
const interviewOverall = latestScore(ctx.qscoreSignals, "interview.overall_score");
const roleplayComms = latestScore(ctx.qscoreSignals, "roleplay.communication_effectiveness");
const resumeSession = serviceSession(ctx, "resume") ?? serviceSession(ctx, "resume-service");
const interviewSession = serviceSession(ctx, "interview") ?? serviceSession(ctx, "interview-service");
const roleplaySession = serviceSession(ctx, "roleplay") ?? serviceSession(ctx, "roleplay-service");
const interviewReview = serviceEvent(ctx, "interview.", "review");
const roleplayReview = serviceEvent(ctx, "roleplay.", "review");
const resumeAnalysis = serviceEvent(ctx, "resume.", "analysis");
if (ctx.qscore || ctx.qscoreSignals.length) {
pushSeed(seeds, {
moduleId: "suggestions",
title: qscore >= 80 ? "Protect your Q Score momentum" : "Raise your Q Score next",
subtitle: qscore >= 80 ? `Readiness is trending at ${qscore}. Keep one proof action moving.` : `Current estimate is ${qscore || 64}. Resume + mock practice are the fastest signals.`,
tag: "Q Score",
urgency: qscore >= 80 ? "today" : "now",
href: SERVICE_HREFS.qscore,
source: "qscore",
priority: 95,
});
}
if (ats !== undefined) {
pushSeed(seeds, {
moduleId: "suggestions",
title: ats >= 80 ? "ATS is demo-ready" : "Resume ATS needs one pass",
subtitle: ats >= 80 ? `ATS ${Math.round(ats)} — review role-specific keywords before applying.` : `ATS ${Math.round(ats)} — add JD keywords and measurable bullets.`,
tag: ats >= 80 ? "Ready" : "Fix",
urgency: ats >= 80 ? "today" : "now",
href: SERVICE_HREFS.resume,
source: "resume",
priority: 92,
});
}
for (const mission of ctx.activeMissions.slice(0, 3)) {
pushSeed(seeds, {
moduleId: "missions",
title: `${mission.title} — ${mission.progressPercent}%`,
subtitle: mission.currentStageId ? `Current stage: ${mission.currentStageId.replaceAll("-", " ")}` : "Next action is ready on the mission dashboard.",
tag: mission.status === "paused" ? "Paused" : "Active",
urgency: mission.status === "paused" ? "soon" : "today",
href: SERVICE_HREFS.mission,
source: "mission",
priority: 90 - mission.progressPercent,
});
}
const pendingArtifacts = ctx.artifacts.filter((a) => a.status === "draft" || a.status === "ready").slice(0, 2);
for (const artifact of pendingArtifacts) {
pushSeed(seeds, {
moduleId: "missions",
title: `${artifact.title} is ${artifact.status}`,
subtitle: artifact.summary ?? "Review the artifact from your mission dashboard.",
tag: artifact.status === "ready" ? "Review" : "Draft",
urgency: artifact.status === "ready" ? "now" : "soon",
href: SERVICE_HREFS.mission,
source: "mission",
priority: 88,
});
}
pushSeed(seeds, {
moduleId: "social",
title: "Turn proof into LinkedIn updates",
subtitle: ctx.artifacts.length ? `${ctx.artifacts.length} artifact${ctx.artifacts.length === 1 ? "" : "s"} can feed headline, featured, or post ideas.` : "Connect LinkedIn and use mission proof to improve your profile.",
tag: ctx.artifacts.length ? "Proof" : "Setup",
urgency: ctx.artifacts.length ? "today" : "soon",
href: SERVICE_HREFS.social,
source: "social",
priority: 70,
});
if (resumeAnalysis || resumeSession || ats !== undefined) {
pushSeed(seeds, {
moduleId: "productivity",
title: ats !== undefined ? `Resume ATS ${Math.round(ats)}` : "Resume analysis is ready",
subtitle: ats !== undefined && ats >= 80 ? "Use this version for role-fit scan or final polish." : "Open Resume Builder for recommendations and bullet fixes.",
tag: "Resume",
urgency: ats !== undefined && ats < 75 ? "now" : "today",
href: SERVICE_HREFS.resume,
source: "resume",
priority: 90,
});
}
if (interviewReview || interviewOverall !== undefined || interviewSession) {
pushSeed(seeds, {
moduleId: "productivity",
title: interviewOverall !== undefined ? `Mock interview score ${Math.round(interviewOverall)}` : "Mock interview review is tracking",
subtitle: interviewReview?.type.includes("processing") ? "Review is still processing; check back from the interview page." : "Open interview practice for review, next drill, or a new session.",
tag: interviewReview?.type.includes("processing") ? "Wait" : "Mock",
urgency: interviewReview?.type.includes("processing") ? "soon" : "today",
href: SERVICE_HREFS.interview,
source: "interview",
priority: 86,
});
} else {
pushSeed(seeds, { moduleId: "productivity", title: "Schedule a mock interview", subtitle: "Generate a behavioral or role-related session from your target role.", tag: "Mock", urgency: "soon", href: SERVICE_HREFS.interview, source: "interview", priority: 72 });
}
if (roleplayReview || roleplayComms !== undefined || roleplaySession) {
pushSeed(seeds, {
moduleId: "productivity",
title: roleplayComms !== undefined ? `Roleplay communication ${Math.round(roleplayComms)}` : "Roleplay scenario is ready",
subtitle: "Practice recruiter, manager, salary, or stakeholder conversations.",
tag: "Roleplay",
urgency: "soon",
href: SERVICE_HREFS.roleplay,
source: "roleplay",
priority: 78,
});
}
if (!ctx.activeMissions.length) {
pushSeed(seeds, { moduleId: "missions", title: "Start Interview-to-Offer", subtitle: "Bundle resume fit, mock practice, and Q Score deltas into one journey.", tag: "Begin", urgency: "today", href: SERVICE_HREFS.mission, source: "mission", priority: 80 });
}
return seeds;
}
async function collectContext(userId: string): Promise<HomeContext> {
const [user] = await db.select({ id: users.id, email: users.email, displayName: users.displayName }).from(users).where(eq(users.id, userId)).limit(1);
const [qscore] = await db.select().from(growQscoreProjectionState).where(eq(growQscoreProjectionState.userId, userId)).limit(1);
const qscoreSignals = await db
.select({ signalId: growQscoreLatest.signalId, score: growQscoreLatest.score, source: growQscoreLatest.source, updatedAt: growQscoreLatest.updatedAt })
.from(growQscoreLatest)
.where(eq(growQscoreLatest.userId, userId))
.orderBy(desc(growQscoreLatest.updatedAt))
.limit(30);
const activeMissions = await db
.select({ instanceId: growActiveMissions.instanceId, missionId: growActiveMissions.missionId, title: growActiveMissions.title, status: growActiveMissions.status, progressPercent: growActiveMissions.progressPercent, currentStageId: growActiveMissions.currentStageId, updatedAt: growActiveMissions.updatedAt })
.from(growActiveMissions)
.where(eq(growActiveMissions.userId, userId))
.orderBy(desc(growActiveMissions.updatedAt))
.limit(6);
const sessions = await db
.select({ serviceId: missionServiceSessions.serviceId, externalId: missionServiceSessions.externalId, status: missionServiceSessions.status, updatedAt: missionServiceSessions.updatedAt, metadata: missionServiceSessions.metadata })
.from(missionServiceSessions)
.where(eq(missionServiceSessions.userId, userId))
.orderBy(desc(missionServiceSessions.updatedAt))
.limit(12);
const artifacts = await db
.select({ serviceId: missionArtifacts.serviceId, type: missionArtifacts.type, title: missionArtifacts.title, status: missionArtifacts.status, summary: missionArtifacts.summary, createdAt: missionArtifacts.createdAt })
.from(missionArtifacts)
.where(eq(missionArtifacts.userId, userId))
.orderBy(desc(missionArtifacts.createdAt))
.limit(12);
const events = await db
.select({ source: growEvents.source, type: growEvents.type, occurredAt: growEvents.occurredAt, payload: growEvents.payload })
.from(growEvents)
.where(eq(growEvents.userId, userId))
.orderBy(desc(growEvents.occurredAt))
.limit(30);
const serviceResults = await Promise.allSettled([
resumeService.state(userId),
interviewService.pageState(userId),
roleplayService.pageState(userId),
]);
const [resumeState, interviewState, roleplayState] = serviceResults.map((result) => (result.status === "fulfilled" ? result.value : undefined));
return {
user,
qscore: qscore
? {
score: qscore.score,
signalCount: qscore.signalCount,
summary: qscore.summary,
dimensions: isRecord(qscore.dimensions) ? qscore.dimensions : null,
}
: undefined,
qscoreSignals,
activeMissions,
sessions: sessions.map((s) => ({ ...s, metadata: isRecord(s.metadata) ? s.metadata : null })),
artifacts,
events: events.map((e) => ({ ...e, payload: isRecord(e.payload) ? e.payload : {} })),
serviceStates: { resume: resumeState, interview: interviewState, roleplay: roleplayState },
};
}
function rowToNotification(row: GrowHomeNotificationRow): HomeNotification {
return {
id: row.id,
title: row.title,
subtitle: row.subtitle,
tag: row.tag,
urgency: sanitizeUrgency(row.urgency),
href: sanitizeHref(row.href, MODULE_META[row.moduleId].href),
source: (row.source ?? undefined) as HomeSource | undefined,
reason: row.reason ?? undefined,
createdAt: row.createdAt.toISOString(),
};
}
async function readPersistedNotifications(userId: string) {
const now = new Date();
return db
.select()
.from(growHomeNotifications)
.where(and(eq(growHomeNotifications.userId, userId), eq(growHomeNotifications.status, "active"), or(isNull(growHomeNotifications.expiresAt), gt(growHomeNotifications.expiresAt, now))))
.orderBy(desc(growHomeNotifications.priority), desc(growHomeNotifications.createdAt))
.limit(60);
}
async function replaceGeneratedNotifications(userId: string, notifications: Array<SeedNotification>, generatedBy: "deterministic" | "agent") {
await db
.delete(growHomeNotifications)
.where(and(eq(growHomeNotifications.userId, userId), inArray(growHomeNotifications.generatedBy, ["deterministic", "agent"])));
const expiresAt = new Date(Date.now() + EXPIRY_MS);
const rows: NewGrowHomeNotification[] = notifications.map((n) => ({
userId,
moduleId: n.moduleId,
title: n.title,
subtitle: n.subtitle,
tag: n.tag,
urgency: n.urgency,
href: sanitizeHref(n.href, MODULE_META[n.moduleId].href),
source: n.source,
priority: n.priority,
generatedBy,
reason: n.reason,
status: "active",
expiresAt,
updatedAt: new Date(),
}));
if (rows.length) await db.insert(growHomeNotifications).values(rows);
}
function ensureCoverage(seeds: SeedNotification[], fallback: SeedNotification[]) {
const covered = new Set(seeds.map((s) => s.moduleId));
for (const moduleId of MODULE_IDS) {
if (covered.has(moduleId)) continue;
const item = fallback.find((seed) => seed.moduleId === moduleId);
if (item) seeds.push(item);
}
return seeds;
}
function moduleCount(moduleId: HomeModuleId, notifications: HomeNotification[], ctx: HomeContext, mode: HomeFeedResponse["mode"]) {
if (mode === "demo") {
if (moduleId === "missions") return "1 active";
if (moduleId === "productivity") return "4 updates";
if (moduleId === "social") return "3 ready";
if (moduleId === "pathways") return "Soon";
if (moduleId === "rewards") return "Demo";
return String(notifications.length);
}
if (moduleId === "missions") {
if (ctx.activeMissions.length) return `${ctx.activeMissions.length} active`;
return mode === "day1" ? "0" : String(notifications.length);
}
if (moduleId === "productivity") {
const active = ctx.sessions.filter((s) => s.status === "active" || s.status === "configured" || s.status === "processing").length;
return active ? `${active} active` : String(notifications.length);
}
if (moduleId === "pathways") return mode === "day1" ? "Soon" : "Locked";
if (moduleId === "rewards") return mode === "day1" ? "0" : "Demo";
if (moduleId === "social") return mode === "day1" ? "Setup" : `${notifications.length} updates`;
return String(notifications.length);
}
function buildModules(rows: GrowHomeNotificationRow[], ctx: HomeContext, mode: HomeFeedResponse["mode"]): HomeModule[] {
const byModule = new Map<HomeModuleId, HomeNotification[]>();
for (const row of rows) {
const current = byModule.get(row.moduleId) ?? [];
current.push(rowToNotification(row));
byModule.set(row.moduleId, current);
}
return MODULE_IDS.map((moduleId) => {
const notifications = byModule.get(moduleId) ?? [];
return {
...MODULE_META[moduleId],
count: moduleCount(moduleId, notifications, ctx, mode),
notifications,
};
});
}
async function buildIdentity(ctx: HomeContext) {
const score = ctx.qscore?.score && ctx.qscore.score > 0 ? ctx.qscore.score : Math.round(ctx.qscoreSignals.reduce((sum, s) => sum + s.score, 0) / Math.max(ctx.qscoreSignals.length, 1)) || 47;
const [baselineSnapshot] = await db
.select({ score: qscoreSnapshots.score })
.from(qscoreSnapshots)
.where(and(eq(qscoreSnapshots.userId, ctx.user?.id ?? ""), eq(qscoreSnapshots.snapshotType, "baseline")))
.orderBy(desc(qscoreSnapshots.createdAt))
.limit(1);
const baseline = baselineSnapshot?.score ?? Math.max(35, score - 29);
const from = Math.max(baseline, Math.min(score, score - Math.min(17, Math.max(5, score - baseline))));
const name = ctx.user?.displayName || ctx.user?.email?.split("@")[0] || "GrowQR User";
const completedArtifacts = ctx.artifacts.filter((a) => a.status === "ready" || a.status === "approved").length;
return {
name,
caption: "Your living QR · scan to view profile",
qrSrc: undefined,
qx: { from, to: score, baseline },
glance: [
{ value: Math.max(0, score - baseline), label: "Growth" },
{ value: Math.max(0, ctx.qscore?.signalCount ?? ctx.qscoreSignals.length), label: "Signals" },
{ value: completedArtifacts || ctx.events.length, label: "Done" },
],
};
}
export async function getHomeFeed(userId: string, opts: { refresh?: boolean } = {}): Promise<HomeFeedResponse> {
const ctx = await collectContext(userId);
const persisted = await readPersistedNotifications(userId);
const newest = persisted[0]?.createdAt?.getTime() ?? 0;
const hasDemo = persisted.some((row) => row.generatedBy === "demo");
const fresh = newest > Date.now() - FRESH_MS;
if (persisted.length && (hasDemo || (!opts.refresh && fresh))) {
const mode = hasDemo ? "demo" : hasAnyRealActivity(ctx) ? "dynamic" : "day1";
return {
generatedAt: new Date().toISOString(),
mode,
identity: await buildIdentity(ctx),
modules: buildModules(persisted, ctx, mode),
};
}
const dayOneSeeds = buildDayOneSeeds();
const deterministic = hasAnyRealActivity(ctx) ? buildDynamicSeeds(ctx) : dayOneSeeds;
const agentNotifications = await refineHomeNotificationsWithAgent({
userId,
context: {
qscore: ctx.qscore,
qscoreSignals: ctx.qscoreSignals,
activeMissions: ctx.activeMissions,
sessions: ctx.sessions,
artifacts: ctx.artifacts,
recentEvents: ctx.events,
serviceStates: ctx.serviceStates,
routeRules: SERVICE_HREFS,
},
seeds: deterministic,
});
const generatedBy = agentNotifications.length ? "agent" : "deterministic";
const generatedSeeds: SeedNotification[] = agentNotifications.length
? agentNotifications.map((n, index) => ({
moduleId: n.moduleId,
title: n.title,
subtitle: n.subtitle,
tag: n.tag,
urgency: n.urgency,
href: n.href,
source: n.source,
reason: n.reason,
priority: 100 - index,
}))
: deterministic;
await replaceGeneratedNotifications(userId, ensureCoverage(generatedSeeds, dayOneSeeds), generatedBy);
const rows = await readPersistedNotifications(userId);
const mode = hasAnyRealActivity(ctx) ? "dynamic" : "day1";
return {
generatedAt: new Date().toISOString(),
mode,
identity: await buildIdentity(ctx),
modules: buildModules(rows, ctx, mode),
};
}
export async function dismissHomeNotification(userId: string, notificationId: string) {
await db
.update(growHomeNotifications)
.set({ status: "dismissed", updatedAt: new Date() })
.where(and(eq(growHomeNotifications.userId, userId), eq(growHomeNotifications.id, notificationId)));
}
export async function getHomeFeedDebugCounts(userId: string) {
const [row] = await db
.select({ count: sql<number>`count(*)::int` })
.from(growHomeNotifications)
.where(eq(growHomeNotifications.userId, userId));
return { notifications: row?.count ?? 0 };
}
+165
View File
@@ -0,0 +1,165 @@
import "dotenv/config";
import { and, eq, inArray } from "drizzle-orm";
import { db } from "../db/client.js";
import { growActiveMissions, growHomeNotifications, growQscoreLatest, growQscoreProjectionState, missionArtifacts, missionServiceSessions, users, type NewGrowHomeNotification } from "../db/schema.js";
import type { HomeModuleId, HomeSource, HomeUrgency } from "./types.js";
type DemoNotification = {
moduleId: HomeModuleId;
title: string;
subtitle: string;
tag: string;
urgency: HomeUrgency;
href: string;
source: HomeSource;
priority: number;
reason?: string;
};
const demoNotifications: DemoNotification[] = [
{ moduleId: "suggestions", title: "Approve resume v6", subtitle: "ATS 78 → 86 · two PM bullets are ready to ship.", tag: "Urgent", urgency: "now", href: "/agents/resume", source: "resume", priority: 120 },
{ moduleId: "suggestions", title: "Mock follow-up at 6:30 PM", subtitle: "Behavioral drill based on yesterday's review gaps.", tag: "Today", urgency: "today", href: "/agents/interview", source: "interview", priority: 116 },
{ moduleId: "suggestions", title: "Practice recruiter pushback", subtitle: "Roleplay can tighten your compensation and notice-period answer.", tag: "Soon", urgency: "soon", href: "/agents/roleplay", source: "roleplay", priority: 110 },
{ moduleId: "missions", title: "Interview-to-Offer — 72%", subtitle: "Resume fit done · mock review ready · final report locked.", tag: "Active", urgency: "today", href: "/missions", source: "mission", priority: 115 },
{ moduleId: "missions", title: "2 approvals pending", subtitle: "Resume v6 and Mock #4 feedback need your confirmation.", tag: "Action", urgency: "now", href: "/missions", source: "mission", priority: 113 },
{ moduleId: "missions", title: "Final readiness unlock", subtitle: "Complete one roleplay recovery drill to generate the final checklist.", tag: "Next", urgency: "soon", href: "/missions", source: "mission", priority: 106 },
{ moduleId: "social", title: "LinkedIn headline v3 ready", subtitle: "Clearer target: Product Intern · FinTech · Growth Systems.", tag: "Ready", urgency: "today", href: "/social", source: "social", priority: 104 },
{ moduleId: "social", title: "Featured section has 3 proof pins", subtitle: "Use resume scan, mock review, and Q Score delta as credibility blocks.", tag: "Proof", urgency: "soon", href: "/social", source: "social", priority: 100 },
{ moduleId: "social", title: "Banner options queued", subtitle: "Three calm orange/blue layouts are waiting for review.", tag: "Brand", urgency: "soon", href: "/social", source: "social", priority: 96 },
{ moduleId: "pathways", title: "Pathways stay locked for demo", subtitle: "Resume + interview data is enough; pathway service is not enabled yet.", tag: "Soon", urgency: "calm", href: "/pathways", source: "pathways", priority: 70 },
{ moduleId: "pathways", title: "PM SaaS route predicted", subtitle: "Directional only until the pathways service is connected.", tag: "Preview", urgency: "calm", href: "/pathways", source: "pathways", priority: 68 },
{ moduleId: "productivity", title: "Resume ATS 86", subtitle: "Keyword relevance +9 after JD tailoring. Open Resume Builder.", tag: "Resume", urgency: "today", href: "/agents/resume", source: "resume", priority: 118 },
{ moduleId: "productivity", title: "Interview review is ready", subtitle: "Overall 82 · storytelling and concise examples need one more drill.", tag: "Mock", urgency: "now", href: "/agents/interview", source: "interview", priority: 117 },
{ moduleId: "productivity", title: "Roleplay recruiter screen", subtitle: "Scenario ready · handle salary range and start-date objections.", tag: "Roleplay", urgency: "soon", href: "/agents/roleplay", source: "roleplay", priority: 105 },
{ moduleId: "productivity", title: "Q Score now 86", subtitle: "Signals: resume ATS, mock review, and roleplay completion.", tag: "Q Score", urgency: "today", href: "/agents/qscore", source: "qscore", priority: 102 },
{ moduleId: "rewards", title: "Mentor pass preview", subtitle: "Demo reward only · would unlock after 3 completed readiness actions.", tag: "Demo", urgency: "calm", href: "/rewards", source: "rewards", priority: 60 },
{ moduleId: "rewards", title: "Resume Pro credit queued", subtitle: "Tabled for v1; shown as a consistent notification tile.", tag: "Soon", urgency: "calm", href: "/rewards", source: "rewards", priority: 58 },
];
export async function seedDemoHome(userId: string) {
await db.insert(users).values({ id: userId, email: `${userId}@demo.local`, displayName: "Demo User" }).onConflictDoNothing();
await db
.delete(growHomeNotifications)
.where(and(eq(growHomeNotifications.userId, userId), inArray(growHomeNotifications.generatedBy, ["demo", "agent", "deterministic"])));
const expiresAt = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000);
const rows: NewGrowHomeNotification[] = demoNotifications.map((n) => ({
userId,
moduleId: n.moduleId,
title: n.title,
subtitle: n.subtitle,
tag: n.tag,
urgency: n.urgency,
href: n.href,
source: n.source,
priority: n.priority,
generatedBy: "demo",
reason: n.reason ?? "Seeded for multi-day dashboard demo.",
status: "active",
expiresAt,
updatedAt: new Date(),
}));
await db.insert(growHomeNotifications).values(rows);
await db.insert(growQscoreProjectionState).values({
userId,
score: 86,
signalCount: 14,
dimensions: { strengths: ["resume.ats_compatibility", "interview.overall_score"], growth: ["interview.response_clarity", "roleplay.problem_resolution"] },
summary: "Demo readiness score from resume, interview, and roleplay signals.",
updatedAt: new Date(),
}).onConflictDoUpdate({
target: growQscoreProjectionState.userId,
set: {
score: 86,
signalCount: 14,
dimensions: { strengths: ["resume.ats_compatibility", "interview.overall_score"], growth: ["interview.response_clarity", "roleplay.problem_resolution"] },
summary: "Demo readiness score from resume, interview, and roleplay signals.",
updatedAt: new Date(),
},
});
const signalRows = [
["resume.uploaded", 100, "resume-service"],
["resume.ats_compatibility", 86, "resume-service"],
["resume.keyword_relevance", 84, "resume-service"],
["interview.completed", 100, "interview-service"],
["interview.overall_score", 82, "interview-service"],
["interview.response_clarity", 74, "interview-service"],
["roleplay.completed", 100, "roleplay-service"],
["roleplay.communication_effectiveness", 79, "roleplay-service"],
] as const;
for (const [signalId, score, source] of signalRows) {
await db.insert(growQscoreLatest).values({
userId,
signalId,
score,
present: true,
source,
raw: { demo: true },
occurredAt: new Date(),
updatedAt: new Date(),
}).onConflictDoUpdate({
target: [growQscoreLatest.userId, growQscoreLatest.signalId],
set: { score, present: true, source, raw: { demo: true }, occurredAt: new Date(), updatedAt: new Date() },
});
}
await db.insert(growActiveMissions).values({
instanceId: `demo-interview-to-offer-${userId}`,
userId,
missionId: "interview-to-offer",
workflowId: "interview-to-offer",
actorType: "interviewToOfferMissionActor",
title: "Interview-to-Offer Accelerator",
shortTitle: "Interview-to-Offer",
status: "active",
progressPercent: 72,
currentStageId: "mock-interview-review",
goal: "Land a PM internship offer",
mission: { demo: true, missionId: "interview-to-offer" },
snapshot: { demo: true, progressPercent: 72 },
updatedAt: new Date(),
}).onConflictDoUpdate({
target: growActiveMissions.instanceId,
set: { status: "active", progressPercent: 72, currentStageId: "mock-interview-review", snapshot: { demo: true, progressPercent: 72 }, updatedAt: new Date() },
});
await db.insert(missionServiceSessions).values([
{ userId, missionInstanceId: `demo-interview-to-offer-${userId}`, missionId: "interview-to-offer", stageId: "resume-fit-scan", serviceId: "resume-service", externalId: `demo-resume-${userId}`, status: "completed", metadata: { demo: true, ats: 86 }, updatedAt: new Date() },
{ userId, missionInstanceId: `demo-interview-to-offer-${userId}`, missionId: "interview-to-offer", stageId: "mock-interview", serviceId: "interview-service", externalId: `demo-interview-${userId}`, status: "review_ready", metadata: { demo: true, overall: 82 }, updatedAt: new Date() },
{ userId, missionInstanceId: `demo-interview-to-offer-${userId}`, missionId: "interview-to-offer", stageId: "roleplay-recovery", serviceId: "roleplay-service", externalId: `demo-roleplay-${userId}`, status: "configured", metadata: { demo: true }, updatedAt: new Date() },
]).onConflictDoNothing();
await db.insert(missionArtifacts).values([
{ userId, missionInstanceId: `demo-interview-to-offer-${userId}`, missionId: "interview-to-offer", stageId: "resume-fit-scan", serviceId: "resume-service", externalId: `demo-resume-${userId}`, type: "resume_fit_scan", title: "Resume Fit Scan", status: "ready", summary: "ATS 86 with stronger PM keywords.", metadata: { demo: true }, updatedAt: new Date() },
{ userId, missionInstanceId: `demo-interview-to-offer-${userId}`, missionId: "interview-to-offer", stageId: "mock-interview", serviceId: "interview-service", externalId: `demo-interview-${userId}`, type: "mock_interview_review", title: "Mock Interview Review", status: "ready", summary: "Overall 82; improve story brevity and tradeoff framing.", metadata: { demo: true }, updatedAt: new Date() },
]).onConflictDoNothing();
return { inserted: rows.length, userId };
}
const entry = process.argv[1] ?? "";
if (entry.endsWith("seed-demo-home.ts") || entry.endsWith("seed-demo-home.js")) {
const userId = process.env.DEMO_USER_ID;
if (!userId) {
console.error("Set DEMO_USER_ID to seed demo home notifications.");
process.exit(1);
}
seedDemoHome(userId)
.then((result) => {
console.log(JSON.stringify(result, null, 2));
process.exit(0);
})
.catch((err) => {
console.error(err);
process.exit(1);
});
}
+64
View File
@@ -0,0 +1,64 @@
export type HomeModuleId = "suggestions" | "missions" | "social" | "pathways" | "productivity" | "rewards";
export type HomeUrgency = "now" | "today" | "soon" | "calm";
export type HomeAccent = "orange" | "blue" | "teal" | "amber" | "violet";
export type HomeSource = "resume" | "interview" | "roleplay" | "qscore" | "mission" | "social" | "pathways" | "rewards" | "system";
export type HomeNotification = {
id: string;
title: string;
subtitle: string;
tag: string;
urgency: HomeUrgency;
href: string;
source?: HomeSource;
reason?: string;
createdAt: string;
};
export type HomeModule = {
id: HomeModuleId;
label: string;
href: string;
accent: HomeAccent;
count: string;
notifications: HomeNotification[];
};
export type HomeIdentity = {
name: string;
caption: string;
qrSrc?: string;
qx: { from: number; to: number; baseline: number };
glance: { value: number; label: string }[];
};
export type HomeFeedResponse = {
generatedAt: string;
mode: "day1" | "dynamic" | "demo";
identity: HomeIdentity;
modules: HomeModule[];
};
export const MODULE_META: Record<HomeModuleId, Omit<HomeModule, "count" | "notifications">> = {
suggestions: { id: "suggestions", label: "Suggestions", href: "/suggestions", accent: "orange" },
missions: { id: "missions", label: "Missions", href: "/missions", accent: "orange" },
social: { id: "social", label: "Social Branding", href: "/social", accent: "blue" },
pathways: { id: "pathways", label: "Pathways", href: "/pathways", accent: "teal" },
productivity: { id: "productivity", label: "Productivity", href: "/productivity", accent: "orange" },
rewards: { id: "rewards", label: "Rewards", href: "/rewards", accent: "amber" },
};
export const MODULE_IDS: HomeModuleId[] = ["suggestions", "missions", "social", "pathways", "productivity", "rewards"];
export const ALLOWED_NOTIFICATION_HREFS = new Set([
"/suggestions",
"/missions",
"/social",
"/pathways",
"/productivity",
"/rewards",
"/agents/resume",
"/agents/interview",
"/agents/roleplay",
"/agents/qscore",
]);
+2
View File
@@ -17,6 +17,7 @@ import { conversationRoutes } from "./routes/conversations.js";
import { growRoutes } from "./routes/grow.js";
import { missionRoutes } from "./routes/missions.js";
import { eventRoutes } from "./routes/events.js";
import { homeRoutes } from "./routes/home.js";
import { startGrowEventsRedisConsumer } from "./events/redis-consumer.js";
import { db } from "./db/client.js";
import { hydratePortAllocator, reconcileOnBoot, ensureCentralGiteaReady } from "./docker/manager.js";
@@ -85,6 +86,7 @@ async function main() {
app.route("/grow", growRoutes());
app.route("/missions", missionRoutes());
app.route("/events", eventRoutes());
app.route("/home", homeRoutes());
app.route("/conversations", conversationRoutes());
app.route("/opencode", opencodeRoutes());
app.route("/git", gitRoutes());
+34
View File
@@ -0,0 +1,34 @@
import { Hono } from "hono";
import { config } from "../config.js";
import { requireUser, type AuthContext } from "../auth/clerk.js";
import { dismissHomeNotification, getHomeFeed, getHomeFeedDebugCounts } from "../home/home-feed.js";
import { seedDemoHome } from "../home/seed-demo-home.js";
function canSeedDemo(userId: string) {
return config.nodeEnv !== "production" || config.adminUserIds.includes(userId);
}
export function homeRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
app.get("/feed", async (c) => {
const refresh = c.req.query("refresh") === "1" || c.req.query("refresh") === "true";
return c.json(await getHomeFeed(c.get("userId"), { refresh }));
});
app.post("/notifications/:id/dismiss", async (c) => {
await dismissHomeNotification(c.get("userId"), c.req.param("id"));
return c.json({ ok: true });
});
app.get("/debug/counts", async (c) => c.json(await getHomeFeedDebugCounts(c.get("userId"))));
app.post("/demo/seed", async (c) => {
const userId = c.get("userId");
if (!canSeedDemo(userId)) return c.json({ error: "forbidden" }, 403);
return c.json(await seedDemoHome(userId));
});
return app;
}
+88 -28
View File
@@ -1,5 +1,7 @@
import { Hono } from "hono";
import { desc, eq } from "drizzle-orm";
import { requireUser, type AuthContext } from "../auth/clerk.js";
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";
@@ -8,6 +10,18 @@ import { recordGrowEvent } from "../events/record-grow-event.js";
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
import { log } from "../log.js";
const LANDING_AGENTS = [
{ id: "resume", title: "The Resume Expert", agent: "AI Resume Expert", description: "Writes resumes and cover letters that get past ATS and into human hands.", route: "/agents/resume" },
{ id: "interview", title: "The Interviewer", agent: "AI Interviewer", description: "Puts you through real interviews before the real one. Brutal feedback included.", route: "/agents/interview" },
{ id: "roleplay", title: "The Roleplay Coach", agent: "AI Roleplay Coach", description: "Practises the hard conversations with you — so you're never caught off guard.", route: "/agents/roleplay" },
{ id: "assessments", title: "The Skill Trainer", agent: "AI Skill Trainer", description: "Finds exactly what's holding you back — then builds a plan to close it.", route: "/agents/assessments" },
{ id: "social-branding", title: "The Brand Voice", agent: "AI Brand Voice", description: "Makes you impossible to ignore online — posts, profiles, and positioning sorted.", route: "/agents/social-branding" },
{ id: "pathways-report", title: "The Pathway Guide", agent: "AI Pathway Guide", description: "Maps career options, pivots, and what-fits-next at every stage of your journey.", route: "/agents/pathways-report" },
{ id: "courses", title: "The Learning Curator", agent: "AI Learning Curator", description: "Picks only what's worth your time — courses and paths that move you forward.", route: "/agents/courses" },
{ id: "jobs", title: "The Opportunity Finder", agent: "AI Job Finder", description: "Daily, agent-sourced shortlist of roles matched to your QX and goals.", route: "/agents/jobs" },
{ id: "presence", title: "The Presence Coach", agent: "AI Presence Coach", description: "Tells you what to wear, how to speak, and how to carry yourself in every room.", route: "/agents/presence" },
] as const;
const DEFAULT_QSCORE = {
q_score: 76,
profession: "Student",
@@ -70,12 +84,78 @@ function eventTypeForReview(prefix: "interview" | "roleplay", result: Record<str
return `${prefix}.review_processing`;
}
async function proxyResumeRequest(req: Request, rest: string, userId: string) {
const incoming = new URL(req.url);
const normalizedRest = rest
.replace(/^\/+/, "")
// Legacy gateway conveniences mapped onto resume-builder's real v1 API.
.replace(/^resumes\/([^/]+)\/analyze$/, "ai/analyze/$1")
.replace(/^resumes\/([^/]+)\/suggestions$/, "ai/suggestions/$1")
.replace(/^resumes\/([^/]+)\/preview$/, "export/resumes/$1/preview");
const target = new URL(
`/api/v1/${normalizedRest}${incoming.search}`,
config.resumeServiceUrl.replace(/\/$/, ""),
);
const headers = new Headers(req.headers);
headers.delete("host");
headers.delete("cookie");
headers.set("authorization", `Bearer ${config.a2aAllowedKey}`);
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);
app.get("/catalog", (c) => c.json({ services: listServiceCapabilities() }));
app.get("/agents", async (c) => {
const userId = c.get("userId");
const rows = await db.select().from(events).where(eq(events.userId, userId)).orderBy(desc(events.createdAt)).limit(250);
const activeIds = new Set(
rows
.filter((event) => event.type === "agent.activated")
.map((event) => String(event.payload?.agentId ?? ""))
.filter(Boolean),
);
const agents = LANDING_AGENTS.map((agent) => ({ ...agent, active: activeIds.has(agent.id) }));
return c.json({ agents, active: agents.filter((agent) => agent.active), available: agents.filter((agent) => !agent.active) });
});
const ENABLED_AGENT_IDS = new Set(["resume", "interview", "roleplay"]);
app.post("/agents/:agentId/activate", async (c) => {
const userId = c.get("userId");
const agentId = c.req.param("agentId");
const agent = LANDING_AGENTS.find((item) => item.id === agentId);
if (!agent) return c.json({ error: "unknown_agent" }, 404);
if (!ENABLED_AGENT_IDS.has(agentId)) return c.json({ error: "agent_not_available" }, 403);
await db.insert(events).values({
userId,
actorId: "service-gateway",
type: "agent.activated",
payload: { agentId, route: agent.route, dummyPurchase: true },
});
return c.json({ agent: { ...agent, active: true } });
});
app.get("/:service/health", async (c) => {
const service = c.req.param("service");
if (service === "interview") return c.json(await interviewService.health());
@@ -186,39 +266,19 @@ export function serviceRoutes() {
app.post("/roleplay/sessions/:sessionId/video/uploaded", async (c) => c.json(await roleplayService.markVideoUploaded(c.req.param("sessionId"), await c.req.json<JsonObject>())));
app.get("/resume/state/:clerkId", async (c) => c.json(await resumeService.state(c.req.param("clerkId"))));
app.get("/resume/templates", async (c) => c.json(await resumeService.templates()));
app.post("/resume/tasks", async (c) => {
const body = await c.req.json<JsonObject>();
return c.json(await resumeService.task({ ...body, user_id: String(body.user_id ?? c.get("userId")) }));
});
app.get("/resume/resumes", async (c) => c.json(await resumeService.listResumes(c.req.query("clerk_id") ?? "")));
app.post("/resume/resumes", async (c) => c.json(await resumeService.createResume(await c.req.json<JsonObject>())));
app.get("/resume/resumes/:resumeId", async (c) => c.json(await resumeService.getResume(c.req.param("resumeId"))));
app.put("/resume/resumes/:resumeId", async (c) => c.json(await resumeService.updateResume(c.req.param("resumeId"), await c.req.json<JsonObject>())));
app.post("/resume/resumes/:resumeId/analyze", async (c) => {
const userId = c.get("userId");
const resumeId = c.req.param("resumeId");
const payload = (await c.req.json<JsonObject>().catch(() => ({}))) as JsonObject;
const result = await resumeService.analyzeResume(resumeId, payload);
await recordGatewayEvent({
userId,
source: "resume-service",
type: "resume.analysis_completed",
payload: { resumeId, request: payload, result: result as Record<string, unknown> },
correlation: { resumeId },
mission: missionFromBody(payload),
}).catch((err) => log.warn({ err }, "failed to record resume analysis event"));
return c.json(result);
// Frontend Resume Builder routes should preserve the user's Clerk bearer token
// and proxy to resume-builder's authenticated /api/v1 surface. This covers
// resumes, templates, uploads, parsing, exports, cover letters, versions,
// restore, primary selection, and AI endpoints.
app.all("/resume/*", async (c) => {
const rest = c.req.path.split("/resume/")[1] ?? "";
return proxyResumeRequest(c.req.raw, rest, c.get("userId"));
});
app.get("/resume/resumes/:resumeId/suggestions", async (c) => c.json(await resumeService.suggestions(c.req.param("resumeId"))));
app.post("/resume/ai/copilot", async (c) => c.json(await resumeService.copilot(await c.req.json<JsonObject>())));
app.post("/resume/ai/optimize-summary", async (c) => c.json(await resumeService.optimizeSummary(await c.req.json<JsonObject>())));
app.post("/resume/ai/optimize-experience", async (c) => c.json(await resumeService.optimizeExperience(await c.req.json<JsonObject>())));
app.post("/resume/ai/suggest-skills", async (c) => c.json(await resumeService.suggestSkills(await c.req.json<JsonObject>())));
app.post("/resume/ai/generate-summary", async (c) => c.json(await resumeService.generateSummary(await c.req.json<JsonObject>())));
app.get("/resume/resumes/:resumeId/versions", async (c) => c.json(await resumeService.listVersions(c.req.param("resumeId"))));
app.post("/resume/resumes/:resumeId/versions", async (c) => c.json(await resumeService.createVersion(c.req.param("resumeId"), await c.req.json<JsonObject>())));
app.get("/resume/resumes/:resumeId/preview", async (c) => c.json(await resumeService.preview(c.req.param("resumeId"))));
return app;
}