updates
This commit is contained in:
@@ -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);
|
||||
@@ -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
@@ -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")!;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
]);
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user