Compare commits

...

20 Commits

Author SHA1 Message Date
790db95bda feat: back system notification bell with database 2026-07-06 01:46:14 +05:30
Sai-karthik
9062ce9f22 PRM-88 add CareerSprint report KPI backend 2026-07-05 11:43:08 +00:00
Sai-karthik
d252077135 Expose static curator day outcomes 2026-07-04 12:22:29 +00:00
Sai-karthik
5606f987c5 Serve static curator reads without actor startup 2026-07-02 20:19:57 +00:00
Sai-karthik
4353e8fe33 Align curator sprint with static task docs 2026-07-02 19:54:50 +00:00
Sai-karthik
f826b7b9a3 Remove legacy curator ICP shim 2026-07-02 18:03:14 +00:00
Sai-karthik
6432734f74 Split curator ICP and task registries 2026-07-02 17:59:07 +00:00
Sai-karthik
0394dfbca0 Verify static curator handoff registry 2026-07-02 13:18:49 +00:00
Sai-karthik
837a36276a Make curator missions static by ICP sprint 2026-07-02 12:57:38 +00:00
Sai-karthik
a43b7dd9dd Align recovery curator CTA with roleplay 2026-07-01 13:16:36 +00:00
Sai-karthik
4ec3f57210 Clean resume proof curator copy 2026-07-01 12:37:02 +00:00
Sai-karthik
9df22e01e7 Restrict curator streak tasks to active services 2026-07-01 12:27:31 +00:00
Sai-karthik
35052a0ced Merge remote-tracking branch 'origin/fix/curator-streak-agent-mapping' into staging 2026-07-01 11:16:09 +00:00
e1bb3b2b11 fix: map curator tasks to service agents 2026-07-01 16:32:06 +05:30
Sai-karthik
ac66abec28 Merge PR #13: passive cross-service actions 2026-07-01 10:29:09 +00:00
Sai-karthik
7d74f81913 Recover deep-linked curator starts 2026-06-30 15:46:15 +00:00
Sai-karthik
3bba99e874 Recover completed deep-linked curator tasks 2026-06-30 13:31:30 +00:00
Sai-karthik
697040e9d3 Complete deep-linked matchmaking tasks 2026-06-30 13:23:44 +00:00
Sai-karthik
39ba59ab25 Cap home mission cards 2026-06-29 18:44:28 +00:00
Sai-karthik
10478fb035 Fix staging home mission service routing 2026-06-29 18:38:03 +00:00
24 changed files with 1556 additions and 962 deletions

View File

@@ -0,0 +1,33 @@
CREATE TABLE IF NOT EXISTS "system_notifications" (
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
"user_id" text NOT NULL,
"kind" text NOT NULL,
"title" text NOT NULL,
"sub" text NOT NULL,
"when_label" text NOT NULL,
"href" text,
"source" text DEFAULT 'system' NOT NULL,
"source_ref" jsonb DEFAULT '{}'::jsonb NOT NULL,
"read_at" timestamp with time zone,
"occurred_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "system_notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade ON UPDATE no action,
CONSTRAINT "system_notifications_kind_check" CHECK ("kind" IN ('session', 'billing', 'security', 'feature', 'account'))
);
CREATE INDEX IF NOT EXISTS "system_notifications_user_idx" ON "system_notifications" USING btree ("user_id","read_at","occurred_at");
CREATE INDEX IF NOT EXISTS "system_notifications_kind_idx" ON "system_notifications" USING btree ("user_id","kind","occurred_at");
CREATE TABLE IF NOT EXISTS "system_notification_preferences" (
"user_id" text NOT NULL,
"kind" text NOT NULL,
"in_app" boolean DEFAULT true NOT NULL,
"email" boolean DEFAULT true NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "system_notification_preferences_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade ON UPDATE no action,
CONSTRAINT "system_notification_preferences_pk" PRIMARY KEY("user_id","kind"),
CONSTRAINT "system_notification_preferences_kind_check" CHECK ("kind" IN ('session', 'billing', 'security', 'feature', 'account'))
);
CREATE INDEX IF NOT EXISTS "system_notification_preferences_user_idx" ON "system_notification_preferences" USING btree ("user_id");

View File

@@ -85,6 +85,13 @@
"when": 1780481600000,
"tag": "0011_conversation_metadata",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1780481700000,
"tag": "0012_system_notifications",
"breakpoints": true
}
]
}

View File

@@ -9,6 +9,7 @@
"test:onboarding": "tsx scripts/onboarding-ledger.test.ts",
"test:missions": "tsx scripts/mission-lifecycle.test.ts",
"test:passive-actions": "tsx scripts/passive-actions.test.ts",
"test:curator-static": "tsx scripts/curator-static-registry.test.ts",
"start": "node dist/index.js",
"typecheck": "tsc -p tsconfig.json --noEmit",
"workflows:smoke": "tsx src/workflows/smoke-test.ts",

View File

@@ -0,0 +1,128 @@
import { buildServiceCurationPreview } from "../src/v1/curator/curator-store.js";
import type { CuratorIcpId } from "../src/v1/curator/icp-registry.js";
const ICP_IDS: CuratorIcpId[] = [
"student_recent_grad",
"intern",
"fresher_early_professional",
"experienced_professional",
"freelancer",
"founder",
"enterprise",
];
const LIVE_ROUTE_PREFIX: Record<string, string> = {
"courses-service": "/agents/courses",
"resume-service": "/agents/resume",
"interview-service": "/agents/interview/preview",
"roleplay-service": "/agents/roleplay/builder",
"matchmaking-service": "/agents/matchmaking",
};
const EXPECTED_FOCUS: Record<CuratorIcpId, string[]> = {
student_recent_grad: ["Start Your Story", "Discover Your Strengths", "Build Credibility", "Explore Opportunities", "Learn To Grow", "Grow Your Network", "Ready For Tomorrow"],
intern: ["Own Your Journey", "Show Your Growth", "Build Credibility", "Find Better Opportunities", "Strengthen Your Skills", "Expand Your Network", "Step Into Opportunity"],
fresher_early_professional: ["Start Strong", "Build Visibility", "Show Your Potential", "Find Better Opportunities", "Grow Your Skills", "Expand Your Network", "Launch Your Career"],
experienced_professional: ["Lead With Purpose", "Build Executive Presence", "Show Strategic Value", "Expand Your Influence", "Future-Proof Leadership", "Build Strategic Relationships", "Lead The Future"],
freelancer: ["Build Your Brand", "Package Your Value", "Earn Trust", "Find More Clients", "Grow Your Business", "Expand Your Network", "Scale Forward"],
founder: ["Own Your Vision", "Validate Your Idea", "Build Credibility", "Find Your Market", "Grow Smarter", "Build Your Network", "Launch Forward"],
enterprise: ["Know Your Workforce", "Measure Your Talent", "Strengthen Capability", "Build Better Hiring", "Grow Leaders", "Activate Growth", "Future Ready Enterprise"],
};
const EXPECTED_DAY_ONE_TITLES: Record<CuratorIcpId, string[]> = {
student_recent_grad: ["Claim Your Corner", "Know Your Number", "Resume Rescue", "Pitch Perfect"],
intern: ["Claim Your Corner", "Know Your Number", "Resume Reloaded", "Pitch Perfect"],
fresher_early_professional: ["Claim Your Corner", "Know Your Number", "Resume Rescue", "Pitch Perfect"],
experienced_professional: ["Own Your Executive Brand", "Know Your Number", "Executive Resume", "Executive Introduction"],
freelancer: ["Claim Your Corner", "Know Your Number", "Portfolio Rescue", "Client Pitch"],
founder: ["Build In Public", "Know Your Number", "Founder Story", "Founder Pitch"],
enterprise: ["Shape Your Story", "Know Your Workforce", "Capability Blueprint", "Leadership Kickoff"],
};
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function routeFor(route: string) {
return new URL(route, "https://app.sai-onchain.me");
}
async function main() {
const startDate = "2026-07-02";
const failures: string[] = [];
for (const icpId of ICP_IDS) {
const preview = await buildServiceCurationPreview({
userId: `curator-static-registry-test-${icpId}`,
startDate,
icpId,
userContext: { targetRole: "Product Manager" },
});
try {
assert(preview.version === "icp-v7-static", `${icpId}: expected static plan version`);
assert(preview.idempotency.promptHash === "static-task-registry", `${icpId}: prompt hash should prove static registry mode`);
assert(preview.plan.days.length === 30, `${icpId}: expected 30 generated days`);
assert(preview.plan.calendarWeeks.length === 5, `${icpId}: expected 5 sprint-relative calendar weeks`);
const firstSevenFocus = preview.plan.days.slice(0, 7).map((day) => day.focus);
assert(JSON.stringify(firstSevenFocus) === JSON.stringify(EXPECTED_FOCUS[icpId]), `${icpId}: first 7 focus labels must match 7-day sprint doc`);
assert(JSON.stringify(firstSevenFocus) === JSON.stringify(preview.plan.days.slice(7, 14).map((day) => day.focus)), `${icpId}: 7-day sprint should repeat after day 7 for now`);
const dayOneTitles = preview.plan.days[0]?.tasks.map((task) => task.title);
assert(JSON.stringify(dayOneTitles) === JSON.stringify(EXPECTED_DAY_ONE_TITLES[icpId]), `${icpId}: day 1 task titles must match docs`);
for (const day of preview.plan.days) {
assert(day.generationStatus === "seeded", `${icpId} day ${day.dayIndex}: generation status must stay seeded`);
assert(!day.adaptationReason, `${icpId} day ${day.dayIndex}: adaptation reason should be absent`);
assert(day.tasks.length === 4, `${icpId} day ${day.dayIndex}: expected exactly 4 task objects`);
const taskIds = new Set(day.tasks.map((task) => task.id));
const stageIds = new Set(day.tasks.map((task) => task.stageId));
assert(taskIds.size === 4, `${icpId} day ${day.dayIndex}: task IDs must be unique`);
assert(stageIds.size === 4, `${icpId} day ${day.dayIndex}: stage IDs must be unique`);
for (const task of day.tasks) {
assert(task.missionId === "curator-sprint", `${icpId} ${task.id}: missionId mismatch`);
assert(task.missionInstanceId?.startsWith("curator-sprint:icp-v7-static:"), `${icpId} ${task.id}: missionInstanceId should use static version`);
assert(task.stageId?.includes(`${task.serviceId?.replace("-service", "")}`), `${icpId} ${task.id}: stageId should include service segment`);
assert(task.id.includes(`${task.serviceId?.replace("-service", "")}`), `${icpId} ${task.id}: taskId should include service segment`);
assert(task.actorName !== "Vera" && task.actorName !== "Kai" && task.actorName !== "Mira" && task.actorName !== "Lyra", `${icpId} ${task.id}: agent persona leaked into actorName`);
const url = routeFor(task.route);
const livePrefix = task.serviceId ? LIVE_ROUTE_PREFIX[task.serviceId] : undefined;
if (livePrefix) {
assert(url.pathname === livePrefix, `${icpId} ${task.id}: route ${url.pathname} does not match ${livePrefix}`);
} else {
assert(url.pathname === "/agents/service-unavailable", `${icpId} ${task.id}: unsupported service must use unavailable route`);
assert(url.searchParams.get("serviceId") === task.serviceId, `${icpId} ${task.id}: unavailable route must preserve serviceId`);
}
assert(url.searchParams.get("source") === "curator-v1", `${icpId} ${task.id}: route missing curator source`);
assert(url.searchParams.get("curatorTaskId") === task.id, `${icpId} ${task.id}: route curatorTaskId mismatch`);
assert(url.searchParams.get("missionId") === task.missionId, `${icpId} ${task.id}: route missionId mismatch`);
assert(url.searchParams.get("missionInstanceId") === task.missionInstanceId, `${icpId} ${task.id}: route missionInstanceId mismatch`);
assert(url.searchParams.get("stageId") === task.stageId, `${icpId} ${task.id}: route stageId mismatch`);
if (task.serviceId === "interview-service" || task.serviceId === "roleplay-service") {
assert(url.searchParams.get("role") === "Product Manager", `${icpId} ${task.id}: practice route should preserve target role`);
assert(url.searchParams.get("duration") === "5", `${icpId} ${task.id}: practice route should carry duration`);
}
}
}
} catch (err) {
failures.push(err instanceof Error ? err.message : String(err));
}
}
if (failures.length) {
console.error(failures.join("\n"));
process.exit(1);
}
console.log(`curator-static-registry tests passed for ${ICP_IDS.length} ICPs, 30 days each, 4 doc-backed tasks per day`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -8,7 +8,41 @@ async function ensureGrowConversationsMetadataColumn() {
`);
}
async function ensureSystemNotificationsTables() {
await db.execute(`
CREATE TABLE IF NOT EXISTS system_notifications (
id text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
user_id text NOT NULL REFERENCES users(id) ON DELETE cascade,
kind text NOT NULL CHECK (kind IN ('session', 'billing', 'security', 'feature', 'account')),
title text NOT NULL,
sub text NOT NULL,
when_label text NOT NULL,
href text,
source text NOT NULL DEFAULT 'system',
source_ref jsonb NOT NULL DEFAULT '{}'::jsonb,
read_at timestamp with time zone,
occurred_at timestamp with time zone NOT NULL DEFAULT now(),
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now()
)
`);
await db.execute(`CREATE INDEX IF NOT EXISTS system_notifications_user_idx ON system_notifications (user_id, read_at, occurred_at)`);
await db.execute(`CREATE INDEX IF NOT EXISTS system_notifications_kind_idx ON system_notifications (user_id, kind, occurred_at)`);
await db.execute(`
CREATE TABLE IF NOT EXISTS system_notification_preferences (
user_id text NOT NULL REFERENCES users(id) ON DELETE cascade,
kind text NOT NULL CHECK (kind IN ('session', 'billing', 'security', 'feature', 'account')),
in_app boolean NOT NULL DEFAULT true,
email boolean NOT NULL DEFAULT true,
updated_at timestamp with time zone NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, kind)
)
`);
await db.execute(`CREATE INDEX IF NOT EXISTS system_notification_preferences_user_idx ON system_notification_preferences (user_id)`);
}
export async function ensureRuntimeSchema() {
await ensureGrowConversationsMetadataColumn();
await ensureSystemNotificationsTables();
log.info("runtime schema ensured");
}

View File

@@ -589,6 +589,44 @@ export const growHomeNotifications = pgTable(
}),
);
export const systemNotifications = pgTable(
"system_notifications",
{
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
kind: text("kind", { enum: ["session", "billing", "security", "feature", "account"] }).notNull(),
title: text("title").notNull(),
sub: text("sub").notNull(),
whenLabel: text("when_label").notNull(),
href: text("href"),
source: text("source").notNull().default("system"),
sourceRef: jsonb("source_ref").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
readAt: timestamp("read_at", { withTimezone: true }),
occurredAt: timestamp("occurred_at", { withTimezone: true }).defaultNow().notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
userIdx: index("system_notifications_user_idx").on(t.userId, t.readAt, t.occurredAt),
kindIdx: index("system_notifications_kind_idx").on(t.userId, t.kind, t.occurredAt),
}),
);
export const systemNotificationPreferences = pgTable(
"system_notification_preferences",
{
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
kind: text("kind", { enum: ["session", "billing", "security", "feature", "account"] }).notNull(),
inApp: boolean("in_app").notNull().default(true),
email: boolean("email").notNull().default(true),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
pk: primaryKey({ columns: [t.userId, t.kind] }),
userIdx: index("system_notification_preferences_user_idx").on(t.userId),
}),
);
export type GrowEventRow = typeof growEvents.$inferSelect;
export type NewGrowEvent = typeof growEvents.$inferInsert;
export type MissionActionRow = typeof missionActions.$inferSelect;
@@ -598,3 +636,6 @@ export type NewMissionSuggestion = typeof missionSuggestions.$inferInsert;
export type MissionCoachRunRow = typeof missionCoachRuns.$inferSelect;
export type GrowHomeNotificationRow = typeof growHomeNotifications.$inferSelect;
export type NewGrowHomeNotification = typeof growHomeNotifications.$inferInsert;
export type SystemNotificationRow = typeof systemNotifications.$inferSelect;
export type NewSystemNotification = typeof systemNotifications.$inferInsert;
export type SystemNotificationPreferenceRow = typeof systemNotificationPreferences.$inferSelect;

View File

@@ -13,7 +13,6 @@ import {
onboardingCompletedAtFromEvent,
runCuratorOnboardingLoopSafely,
} from "../v1/curator/curator-onboarding-loop.js";
import { ensureOnboardingActiveMissions } from "../missions/lifecycle.js";
export const ONBOARDING_LEDGER_EVENT_TYPES = [
"onboarding.snapshot.saved",
@@ -148,13 +147,12 @@ export async function ensureOnboardingSideEffectsForEvent(event: GrowEventRow) {
source: event.source,
context: onboardingContextFromInput(event.payload),
});
const missions = await ensureOnboardingActiveMissions({
userId: event.userId,
completedAt,
sourceEventId: event.id,
source: event.source,
context: onboardingContextFromInput(event.payload),
});
const missions = {
status: "skipped" as const,
reason: "static_curator_sprint_enabled" as const,
started: [],
existing: [],
};
return { qscoreBaselineSeeded, curatorOnboarding, missions };
}

View File

@@ -31,8 +31,9 @@ const rawFeedSchema = z.object({
notifications: z.array(rawNotificationSchema).min(1).max(24),
});
const HOME_FEED_AGENT_TIMEOUT_MS = Number(process.env.HOME_FEED_AGENT_TIMEOUT_MS ?? 90000);
const HOME_FEED_AGENT_ATTEMPTS = Math.max(1, Number(process.env.HOME_FEED_AGENT_ATTEMPTS ?? 2));
const HOME_FEED_AGENT_ENABLED = process.env.HOME_FEED_AGENT_ENABLED === "true";
const HOME_FEED_AGENT_TIMEOUT_MS = Number(process.env.HOME_FEED_AGENT_TIMEOUT_MS ?? 5000);
const HOME_FEED_AGENT_ATTEMPTS = Math.max(1, Number(process.env.HOME_FEED_AGENT_ATTEMPTS ?? 1));
export type AgentHomeNotification = z.infer<typeof notificationSchema>;
@@ -50,7 +51,6 @@ 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
- /agents/social-branding for LinkedIn/social branding
- /agents/matchmaking for Scout/opportunity matching
@@ -58,7 +58,7 @@ Every notification must point to one of these real dashboard routes:
- /suggestions for broad onboarding/profile suggestions
Every notification object must include:
- moduleId: one of ${MODULE_IDS.join(", ")}
- source: one of resume, interview, roleplay, qscore, mission, social, pathways, rewards, system
- source: one of resume, interview, roleplay, mission, social, pathways, rewards, system
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.`;
@@ -79,8 +79,7 @@ function sourceFromHref(href: string) {
if (href.startsWith("/agents/resume")) return "resume";
if (href.startsWith("/agents/interview")) return "interview";
if (href.startsWith("/agents/roleplay")) return "roleplay";
if (href.startsWith("/agents/qscore")) return "qscore";
if (href.startsWith("/missions")) return "mission";
if (href.startsWith("/missions")) return "mission";
if (href.startsWith("/agents/social-branding")) return "social";
if (href.startsWith("/agents/matchmaking")) return "pathways";
if (href.startsWith("/rewards")) return "rewards";
@@ -97,7 +96,6 @@ function moduleFromSource(source: NonNullable<AgentHomeNotification["source"]>):
}
function tagFromSource(source: NonNullable<AgentHomeNotification["source"]>) {
if (source === "qscore") return "Q Score";
if (source === "mission") return "Mission";
if (source === "roleplay") return "Roleplay";
if (source === "interview") return "Interview";
@@ -112,7 +110,6 @@ function defaultHrefForSource(source: NonNullable<AgentHomeNotification["source"
if (source === "resume") return "/agents/resume";
if (source === "interview") return "/agents/interview";
if (source === "roleplay") return "/agents/roleplay";
if (source === "qscore") return "/agents/qscore";
if (source === "mission") return "/missions";
if (source === "social") return "/agents/social-branding";
if (source === "pathways") return "/agents/matchmaking";
@@ -127,10 +124,12 @@ function normalizeAgentNotification(
const seed = (raw.href ? seeds.find((item) => item.href === raw.href) : undefined)
?? seeds.find((item) => item.title.toLowerCase() === raw.title.toLowerCase());
const inferredSource = raw.source ?? seed?.source;
const moduleId = raw.moduleId ?? seed?.moduleId ?? (inferredSource ? moduleFromSource(inferredSource) : "suggestions");
const rawHref = raw.href ?? seed?.href ?? (inferredSource ? defaultHrefForSource(inferredSource, moduleId) : `/${moduleId}`);
const normalizedSource = inferredSource === "qscore" ? "pathways" : inferredSource;
const moduleId = raw.moduleId ?? seed?.moduleId ?? (normalizedSource ? moduleFromSource(normalizedSource) : "suggestions");
const rawHref = raw.href ?? seed?.href ?? (normalizedSource ? defaultHrefForSource(normalizedSource, moduleId) : `/${moduleId}`);
const href = sanitizeHref(rawHref, moduleId);
const source = raw.source ?? seed?.source ?? sourceFromHref(href);
const seedSource = seed?.source === "qscore" ? "pathways" : seed?.source;
const source = raw.source ?? seedSource ?? sourceFromHref(href);
return notificationSchema.parse({
...raw,
tag: raw.tag ?? seed?.tag ?? tagFromSource(source),
@@ -157,7 +156,8 @@ function completeNotificationsWithSeeds(
for (const seed of seeds) {
if (completed.length >= 6) break;
const candidate = normalizeAgentNotification(seed, seeds);
const candidateSeed = seed.source === "qscore" ? { ...seed, source: "pathways" as const, href: seed.href?.startsWith("/agents/qscore") ? "/agents/matchmaking" : seed.href } : seed;
const candidate = normalizeAgentNotification(candidateSeed, seeds);
const key = notificationKey(candidate);
if (seen.has(key)) continue;
completed.push(candidate);
@@ -184,6 +184,7 @@ export async function refineHomeNotificationsWithAgent(input: {
context: Record<string, unknown>;
seeds: Array<Omit<HomeNotification, "id" | "createdAt"> & { moduleId: HomeModuleId }>;
}): Promise<Array<AgentHomeNotification & { id: string; createdAt: string }>> {
if (!HOME_FEED_AGENT_ENABLED) return [];
if (!config.llmApiKey && config.nodeEnv === "production") {
throw new HomeFeedAgentError("home_feed_agent_missing_llm_api_key");
}

View File

@@ -21,6 +21,7 @@ import { ensureOnboardingBaselineQscoreFromLedger } from "../events/onboarding-l
import { HomeFeedAgentError, refineHomeNotificationsWithAgent } from "./home-feed-agent.js";
import { listAvailableMissionDefinitions } from "../missions/registry.js";
import { listServiceCapabilities } from "../workflows/service-capabilities.js";
import { buildCuratorSprint } from "../v1/curator/curator-store.js";
import {
isAllowedNotificationHref,
MODULE_IDS,
@@ -35,14 +36,13 @@ import {
const FRESH_MS = 10 * 60 * 1000;
const EXPIRY_MS = 24 * 60 * 60 * 1000;
const MISSION_MODULE_LIMIT = 4;
const SERVICE_HREFS = {
resume: buildServiceLink("resume-service", "workspace") ?? "/agents/resume",
interview: buildServiceLink("interview-service", "discovery") ?? "/agents/interview",
roleplay: buildServiceLink("roleplay-service", "discovery") ?? "/agents/roleplay",
qscore: buildServiceLink("qscore-service", "dashboard") ?? "/agents/qscore",
mission: "/missions/active",
social: buildServiceLink("social-branding-service", "profile") ?? "/agents/social-branding",
pathways: buildServiceLink("matchmaking-service", "jobs") ?? "/agents/matchmaking",
rewards: "/rewards",
suggestions: "/suggestions",
@@ -101,10 +101,10 @@ function profileFromPreferences(preferences: Record<string, unknown>) {
};
}
function serviceHref(service: "resume" | "interview" | "roleplay" | "qscore", ctx: HomeContext, mission?: { instanceId?: string; missionId?: string; stageId?: string | null }) {
function serviceHref(service: "resume" | "interview" | "roleplay", ctx: HomeContext, mission?: { instanceId?: string; missionId?: string; stageId?: string | null }) {
const profile = profileFromPreferences(ctx.preferences);
const serviceId = service === "qscore" ? "qscore-service" : `${service}-service`;
const pageId = service === "resume" ? "workspace" : service === "qscore" ? "dashboard" : "setup";
const serviceId = `${service}-service`;
const pageId = service === "resume" ? "workspace" : "setup";
return buildServiceLink(serviceId, pageId, {
source: "home",
missionInstanceId: mission?.instanceId,
@@ -126,7 +126,7 @@ function sourceFromSuggestionRole(role: string): HomeSource {
if (value.includes("resume")) return "resume";
if (value.includes("roleplay")) return "roleplay";
if (value.includes("interview")) return "interview";
if (value.includes("q")) return "qscore";
if (value.includes("match") || value.includes("pathway") || value.includes("job")) return "pathways";
return "mission";
}
@@ -193,9 +193,8 @@ function buildDayOneSeeds(): SeedNotification[] {
{ id: "resume-service", moduleId: "productivity" as const, href: SERVICE_HREFS.resume, source: "resume" as const, urgency: "today" as const },
{ id: "interview-service", moduleId: "productivity" as const, href: SERVICE_HREFS.interview, source: "interview" as const, urgency: "today" as const },
{ id: "roleplay-service", moduleId: "productivity" as const, href: SERVICE_HREFS.roleplay, source: "roleplay" as const, urgency: "soon" as const },
{ id: "qscore-service", moduleId: "suggestions" as const, href: SERVICE_HREFS.qscore, source: "qscore" as const, urgency: "now" as const },
{ id: "social-branding-service", moduleId: "social" as const, href: SERVICE_HREFS.social, source: "social" as const, urgency: "soon" as const },
{ id: "matchmaking-service", moduleId: "pathways" as const, href: SERVICE_HREFS.pathways, source: "pathways" as const, urgency: "calm" as const },
{ id: "courses-service", moduleId: "productivity" as const, href: SERVICE_HREFS.productivity, source: "system" as const, urgency: "soon" as const },
{ id: "matchmaking-service", moduleId: "pathways" as const, href: SERVICE_HREFS.pathways, source: "pathways" as const, urgency: "today" as const },
];
for (const [index, card] of serviceCards.entries()) {
@@ -233,7 +232,14 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
const roleplayReview = serviceEvent(ctx, "roleplay.", "review");
const resumeAnalysis = serviceEvent(ctx, "resume.", "analysis");
for (const suggestion of ctx.missionSuggestions.slice(0, 5)) {
const visibleMissionSuggestions = ctx.missionSuggestions
.filter((suggestion) => {
const haystack = `${suggestion.role} ${suggestion.title} ${suggestion.body} ${suggestion.ctaLabel} ${suggestion.ctaHref}`.toLowerCase();
return !haystack.includes("qscore") && !haystack.includes("q score") && !haystack.includes("assessment") && !haystack.includes("/agents/qscore");
})
.slice(0, 5);
for (const suggestion of visibleMissionSuggestions) {
const mission = ctx.activeMissions.find((item) => item.instanceId === suggestion.missionInstanceId);
const source = sourceFromSuggestionRole(suggestion.role);
const href = sanitizeHref(suggestion.ctaHref, mission ? `/missions/active?missionInstanceId=${encodeURIComponent(mission.instanceId)}` : SERVICE_HREFS.mission);
@@ -276,13 +282,13 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
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 for ${profile.targetRole}.` : `Current estimate is ${qscore || 64}. Resume + mock practice are fastest for ${profile.targetRole}.`,
tag: "Q Score",
moduleId: "pathways",
title: qscore >= 80 ? "Review your best job matches" : "Find better-fit job matches",
subtitle: qscore >= 80 ? `Your profile signals are strong enough to compare matched roles for ${profile.targetRole}.` : `Use resume and interview signals to surface roles that fit ${profile.targetRole}.`,
tag: "Matches",
urgency: qscore >= 80 ? "today" : "now",
href: serviceHref("qscore", ctx),
source: "qscore",
href: SERVICE_HREFS.pathways,
source: "pathways",
priority: 95,
});
}
@@ -300,7 +306,7 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
});
}
for (const mission of ctx.activeMissions.slice(0, 3)) {
for (const mission of ctx.activeMissions.slice(0, MISSION_MODULE_LIMIT)) {
pushSeed(seeds, {
moduleId: "missions",
title: `${mission.title}${mission.progressPercent}%`,
@@ -327,17 +333,6 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
});
}
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 ${profile.targetRole} 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",
@@ -381,7 +376,7 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
}
if (!ctx.activeMissions.length) {
pushSeed(seeds, { moduleId: "missions", title: "Start Interview-to-Offer", subtitle: `Bundle resume fit, mock practice, and Q Score deltas for ${profile.targetRole}.`, tag: "Begin", urgency: "today", href: "/missions/available", source: "mission", priority: 80 });
pushSeed(seeds, { moduleId: "missions", title: "Start Interview-to-Offer", subtitle: `Bundle resume fit, mock practice, and matched roles for ${profile.targetRole}.`, tag: "Begin", urgency: "today", href: "/missions/available", source: "mission", priority: 80 });
}
return seeds;
@@ -399,7 +394,7 @@ async function collectContext(userId: string, input: { userProfile?: Record<stri
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))
.where(and(eq(growActiveMissions.userId, userId), eq(growActiveMissions.status, "active")))
.orderBy(desc(growActiveMissions.updatedAt))
.limit(6);
const suggestions = await db
@@ -501,7 +496,6 @@ function hasLegacyMockSeed(rows: GrowHomeNotificationRow[]) {
"Complete your QX self-check",
"Create your interview room",
"Browse 1 career pathway",
"Start with your Q Score",
"Explore Interview-to-Offer",
"Pathways are warming up",
"Open Resume Builder",
@@ -557,8 +551,7 @@ function moduleCount(moduleId: HomeModuleId, notifications: HomeNotification[],
return String(notifications.length);
}
if (moduleId === "missions") {
if (ctx.activeMissions.length) return `${ctx.activeMissions.length} active`;
return mode === "day1" ? "0" : String(notifications.length);
return mode === "day1" && notifications.length === 0 ? "0" : String(Math.min(notifications.length, MISSION_MODULE_LIMIT));
}
if (moduleId === "productivity") {
const active = ctx.sessions.filter((s) => s.status === "active" || s.status === "configured" || s.status === "processing").length;
@@ -580,10 +573,11 @@ function buildModules(rows: GrowHomeNotificationRow[], ctx: HomeContext, mode: H
return MODULE_IDS.map((moduleId) => {
const notifications = byModule.get(moduleId) ?? [];
const visibleNotifications = moduleId === "missions" ? notifications.slice(0, MISSION_MODULE_LIMIT) : notifications;
return {
...MODULE_META[moduleId],
count: moduleCount(moduleId, notifications, ctx, mode),
notifications,
count: moduleCount(moduleId, visibleNotifications, ctx, mode),
notifications: visibleNotifications,
};
});
}
@@ -616,6 +610,7 @@ async function buildIdentity(ctx: HomeContext) {
export async function getHomeFeed(userId: string, opts: { refresh?: boolean; userProfile?: Record<string, unknown>; preferences?: Record<string, unknown> } = {}): Promise<HomeFeedResponse> {
await ensureOnboardingBaselineQscoreFromLedger(userId);
await buildCuratorSprint(userId);
const ctx = await collectContext(userId, { userProfile: opts.userProfile, preferences: opts.preferences });
const persisted = await readPersistedNotifications(userId);
const newest = persisted[0]?.createdAt?.getTime() ?? 0;

View File

@@ -0,0 +1,224 @@
import { and, desc, eq, inArray, sql } from "drizzle-orm";
import { db } from "../db/client.js";
import {
systemNotificationPreferences,
systemNotifications,
type SystemNotificationPreferenceRow,
type SystemNotificationRow,
} from "../db/schema.js";
export const SYSTEM_NOTIFICATION_KINDS = ["session", "billing", "security", "feature", "account"] as const;
export type SystemNotificationKind = typeof SYSTEM_NOTIFICATION_KINDS[number];
export type SystemNotificationPreference = { inApp: boolean; email: boolean };
export type SystemNotificationPreferences = Record<SystemNotificationKind, SystemNotificationPreference>;
export type PublicSystemNotification = {
id: string;
kind: SystemNotificationKind;
title: string;
sub: string;
when: string;
read: boolean;
href?: string;
occurredAt: string;
};
const DEFAULT_PREF: SystemNotificationPreference = { inApp: true, email: true };
export const DEFAULT_SYSTEM_NOTIFICATION_PREFERENCES = Object.fromEntries(
SYSTEM_NOTIFICATION_KINDS.map((kind) => [kind, { ...DEFAULT_PREF }]),
) as SystemNotificationPreferences;
type SeedSystemNotification = {
id: string;
kind: SystemNotificationKind;
title: string;
sub: string;
whenLabel: string;
href?: string;
source: string;
sourceRef?: Record<string, unknown>;
};
function seedRows(userId: string): SeedSystemNotification[] {
return [
{
id: `system:${userId}:session-current-device`,
kind: "session",
title: "Session active on this device",
sub: "You are signed in securely on the current browser.",
whenLabel: "Now",
href: "/settings",
source: "auth",
},
{
id: `system:${userId}:security-profile-review`,
kind: "security",
title: "Security review available",
sub: "Review connected sources and account access from settings.",
whenLabel: "Today",
href: "/settings",
source: "security",
},
{
id: `system:${userId}:feature-dashboard-refresh`,
kind: "feature",
title: "Dashboard experience updated",
sub: "The home dashboard now separates agent inbox items from account notifications.",
whenLabel: "Today",
href: "/",
source: "release",
},
{
id: `system:${userId}:account-preferences-ready`,
kind: "account",
title: "Notification preferences are ready",
sub: "Control in-app and email alerts by category in Settings.",
whenLabel: "Today",
href: "/settings",
source: "account",
},
];
}
function isSystemNotificationKind(value: string): value is SystemNotificationKind {
return (SYSTEM_NOTIFICATION_KINDS as readonly string[]).includes(value);
}
function normalizePreferences(rows: SystemNotificationPreferenceRow[]): SystemNotificationPreferences {
const preferences = { ...DEFAULT_SYSTEM_NOTIFICATION_PREFERENCES };
for (const row of rows) {
if (!isSystemNotificationKind(row.kind)) continue;
preferences[row.kind] = { inApp: row.inApp, email: row.email };
}
return preferences;
}
function publicNotification(row: SystemNotificationRow): PublicSystemNotification {
return {
id: row.id,
kind: row.kind as SystemNotificationKind,
title: row.title,
sub: row.sub,
when: row.whenLabel,
read: Boolean(row.readAt),
href: row.href ?? undefined,
occurredAt: row.occurredAt.toISOString(),
};
}
export async function ensureDefaultSystemNotifications(userId: string) {
const now = new Date();
const rows = seedRows(userId).map((item) => ({
...item,
userId,
sourceRef: item.sourceRef ?? {},
updatedAt: now,
}));
await db
.insert(systemNotifications)
.values(rows)
.onConflictDoUpdate({
target: systemNotifications.id,
set: {
title: sql`excluded.title`,
sub: sql`excluded.sub`,
whenLabel: sql`excluded.when_label`,
href: sql`excluded.href`,
source: sql`excluded.source`,
sourceRef: sql`excluded.source_ref`,
updatedAt: now,
},
});
}
export async function ensureSystemNotificationPreferences(userId: string) {
const now = new Date();
await db
.insert(systemNotificationPreferences)
.values(SYSTEM_NOTIFICATION_KINDS.map((kind) => ({ userId, kind, ...DEFAULT_PREF, updatedAt: now })))
.onConflictDoNothing();
}
export async function getSystemNotificationPreferences(userId: string) {
await ensureSystemNotificationPreferences(userId);
const rows = await db
.select()
.from(systemNotificationPreferences)
.where(eq(systemNotificationPreferences.userId, userId));
return normalizePreferences(rows);
}
export async function updateSystemNotificationPreferences(userId: string, preferences: Partial<Record<SystemNotificationKind, Partial<SystemNotificationPreference>>>) {
await ensureSystemNotificationPreferences(userId);
const now = new Date();
const current = await getSystemNotificationPreferences(userId);
for (const [kind, value] of Object.entries(preferences)) {
if (!isSystemNotificationKind(kind)) continue;
const next = {
inApp: value?.inApp ?? current[kind].inApp,
email: value?.email ?? current[kind].email,
};
await db
.insert(systemNotificationPreferences)
.values({
userId,
kind,
inApp: next.inApp,
email: next.email,
updatedAt: now,
})
.onConflictDoUpdate({
target: [systemNotificationPreferences.userId, systemNotificationPreferences.kind],
set: {
inApp: next.inApp,
email: next.email,
updatedAt: now,
},
});
}
return getSystemNotificationPreferences(userId);
}
export async function listSystemNotifications(userId: string) {
await ensureDefaultSystemNotifications(userId);
const preferences = await getSystemNotificationPreferences(userId);
const enabledKinds = SYSTEM_NOTIFICATION_KINDS.filter((kind) => preferences[kind].inApp);
if (!enabledKinds.length) {
return { notifications: [], preferences };
}
const rows = await db
.select()
.from(systemNotifications)
.where(and(eq(systemNotifications.userId, userId), inArray(systemNotifications.kind, enabledKinds)))
.orderBy(desc(systemNotifications.occurredAt), desc(systemNotifications.createdAt))
.limit(50);
return {
notifications: rows.map(publicNotification),
preferences,
};
}
export async function markSystemNotificationRead(userId: string, notificationId: string) {
const [row] = await db
.update(systemNotifications)
.set({ readAt: new Date(), updatedAt: new Date() })
.where(and(eq(systemNotifications.userId, userId), eq(systemNotifications.id, notificationId)))
.returning();
return row ? publicNotification(row) : null;
}
export async function markAllSystemNotificationsRead(userId: string) {
await db
.update(systemNotifications)
.set({ readAt: new Date(), updatedAt: new Date() })
.where(and(eq(systemNotifications.userId, userId), sql`${systemNotifications.readAt} is null`));
return listSystemNotifications(userId);
}

View File

@@ -6,6 +6,15 @@ import { HomeFeedAgentError } from "../home/home-feed-agent.js";
import { seedDemoHome } from "../home/seed-demo-home.js";
import { getRequestUserProfile } from "../services/user-context.js";
import { log } from "../log.js";
import {
getSystemNotificationPreferences,
listSystemNotifications,
markAllSystemNotificationsRead,
markSystemNotificationRead,
updateSystemNotificationPreferences,
type SystemNotificationKind,
type SystemNotificationPreference,
} from "../notifications/system-notifications.js";
function canSeedDemo(userId: string) {
return config.nodeEnv !== "production" || config.adminUserIds.includes(userId);
@@ -44,6 +53,32 @@ export function homeRoutes() {
return c.json({ ok: true });
});
app.get("/system-notifications", async (c) => {
return c.json(await listSystemNotifications(c.get("userId")));
});
app.post("/system-notifications/:id/read", async (c) => {
const notification = await markSystemNotificationRead(c.get("userId"), c.req.param("id"));
if (!notification) return c.json({ error: "notification_not_found" }, 404);
return c.json({ notification });
});
app.post("/system-notifications/read-all", async (c) => {
return c.json(await markAllSystemNotificationsRead(c.get("userId")));
});
app.get("/system-notifications/preferences", async (c) => {
return c.json({ preferences: await getSystemNotificationPreferences(c.get("userId")) });
});
app.put("/system-notifications/preferences", async (c) => {
const body = await c.req.json().catch(() => ({}));
const preferences = typeof body === "object" && body !== null && "preferences" in body
? (body as { preferences?: Partial<Record<SystemNotificationKind, Partial<SystemNotificationPreference>>> }).preferences ?? {}
: {};
return c.json({ preferences: await updateSystemNotificationPreferences(c.get("userId"), preferences) });
});
app.get("/debug/counts", async (c) => c.json(await getHomeFeedDebugCounts(c.get("userId"))));
app.post("/demo/seed", async (c) => {

View File

@@ -909,6 +909,7 @@ export function serviceRoutes() {
taskId: curatorTaskIdFromRequest(c.req.raw, body),
externalId: getString(result.task_id ?? result.taskId),
},
mission: missionFromRequest(c.req.raw, body),
}).catch((err) => log.warn({ err, userId, action }, "failed to record matchmaking workflow event"));
return c.json(result);
});

View File

@@ -13,7 +13,9 @@ export type ServiceId =
| "resume-service"
| "cover-letter-service"
| "qscore-service"
| "social-branding-service";
| "social-branding-service"
| "expert-service"
| "events-service";
export type ServiceCategory = "practice" | "learning" | "opportunity" | "document" | "measurement" | "profile";
@@ -131,6 +133,15 @@ function getSessionId(detail?: Record<string, unknown>) {
const frontendBaseUrl = config.workflowsDashboardUrl.replace(/\/$/, "");
const allowedServiceIds = new Set<ServiceId>([
"interview-service",
"matchmaking-service",
"roleplay-service",
"courses-service",
"resume-service",
"qscore-service",
]);
const serviceRegistry: ServiceRecord[] = [
{
id: "interview-service",
@@ -837,12 +848,12 @@ export function normalizeServiceId(serviceId?: string | null): ServiceId | undef
}
export function listServices() {
return serviceRegistry;
return serviceRegistry.filter((service) => allowedServiceIds.has(service.id));
}
export function getService(serviceId?: string | null) {
const normalized = normalizeServiceId(serviceId);
return normalized ? serviceRegistry.find((service) => service.id === normalized) : undefined;
return normalized ? listServices().find((service) => service.id === normalized) : undefined;
}
export function getServiceBackend(serviceId?: string | null) {
@@ -881,7 +892,7 @@ export function buildServiceLink(serviceId: string | undefined, pageId?: string,
}
export function listServicesForCatalog() {
return serviceRegistry.map((service) => ({
return listServices().map((service) => ({
id: service.id,
label: service.label,
description: service.description,
@@ -957,13 +968,16 @@ function curatorBaseParams(input: CuratorRouteInput) {
};
}
function unavailableServiceRoute(input: CuratorRouteInput) {
return appendQuery("/agents/service-unavailable", {
...curatorBaseParams(input),
serviceId: input.serviceId,
});
}
export function buildCuratorServiceRoute(input: CuratorRouteInput) {
const service = getService(input.serviceId);
if (!service) {
return input.missionInstanceId
? appendQuery("/missions/active", { missionInstanceId: input.missionInstanceId })
: "/missions/active";
}
if (!service) return unavailableServiceRoute(input);
const state: QueryState = {
...curatorBaseParams(input),
@@ -990,8 +1004,16 @@ export function buildCuratorServiceRoute(input: CuratorRouteInput) {
?? appendQuery("/missions/active", { missionInstanceId: input.missionInstanceId });
}
const unavailableServiceLabels: Record<string, string> = {
"social-branding-service": "Social Branding",
"qscore-service": "Q Score",
"assessment-service": "Assessment",
"expert-service": "Expert",
"events-service": "Events",
};
export function getServiceDisplayName(serviceId?: string, fallback = "Mission planner") {
return getService(serviceId)?.label ?? fallback;
return getService(serviceId)?.label ?? (serviceId ? unavailableServiceLabels[serviceId] : undefined) ?? fallback;
}
export function getServiceToolName(serviceId?: string) {

View File

@@ -11,7 +11,7 @@ Completion is event gated. A checkbox or chat message cannot complete a task unl
## Service Curation Layer
- `curator-icp-playbooks.ts` defines ICP playbooks and maps each persona goal to registry-backed service actions.
- `icp-registry.ts` defines ICP playbooks and maps each persona goal to registry-backed service actions.
- `curator-user-context.ts` assembles deterministic user context from Grow events and QScore projection state.
- `curator-prompt-builder.ts` builds the LLM-ready curation prompt and stable prompt hash.
- `curator-store.ts` keeps generation idempotent by storing sprint starts in `grow_events` with the plan version, ICP, user context, prompt hash, playbook, plan hash, and 30-day plan days.
@@ -28,3 +28,5 @@ Completion is event gated. A checkbox or chat message cannot complete a task unl
- Before generation, the loop snapshots onboarding context into `grow_events` so curation sees the user-service profile/preferences. Event-only triggers also attempt an internal user-service fetch via the service-token path.
- Idempotency is based on the one-time `curator.onboarding_plan.ready` event. Retries do not duplicate the plan-ready analytics event or in-app notification.
- The loop stores the sprint as `curator.sprint.started`, emits `curator.onboarding_plan.ready` with weekly themes and Day 1 task links, and creates a persistent home notification pointing users to their active plan.
- `task-registry.ts` defines the static day-wise task registry and maps those tasks to service handoffs.

View File

@@ -4,7 +4,7 @@ import { curatorPlanSchema, curatorSprintResponseSchema, type CuratorImprovement
import { emitCuratorEvent } from "./curator-events.js";
import { runCuratorChat } from "./curator-agent.js";
import { prepareHandoffForTask } from "./curator-tools.js";
import type { CuratorIcpId } from "./curator-icp-playbooks.js";
import type { CuratorIcpId } from "./icp-registry.js";
import { runCuratorOnboardingLoop } from "./curator-onboarding-loop.js";
type CuratorActorState = {
@@ -16,6 +16,29 @@ type CuratorActorState = {
lastEventId?: string;
};
function addDaysIsoLocal(startDate: string, days: number) {
const date = new Date(`${startDate}T00:00:00.000Z`);
if (Number.isNaN(date.getTime())) return undefined;
date.setUTCDate(date.getUTCDate() + days);
return date.toISOString().slice(0, 10);
}
function dateFromTaskId(taskId: string) {
const match = /:icp-v[^:]+:(\d{4}-\d{2}-\d{2}):day-(\d+):/.exec(taskId);
if (!match) return undefined;
const startDate = match[1];
const daySegment = match[2];
if (!startDate || !daySegment) return undefined;
const dayIndex = Number(daySegment);
if (!Number.isFinite(dayIndex) || dayIndex < 1) return undefined;
return addDaysIsoLocal(startDate, dayIndex - 1);
}
function taskActionDate(input: { taskId: string; date?: string }) {
return input.date ?? dateFromTaskId(input.taskId) ?? todayIsoDate();
}
function touch(c: { state: CuratorActorState }, input: { userId: string }) {
if (c.state.userId && c.state.userId !== input.userId) throw new Error("curatorActor initialized for a different user");
c.state.userId = input.userId;
@@ -88,9 +111,16 @@ export const curatorService = {
},
async startTask(input: { userId: string; taskId: string; date?: string }) {
const date = input.date ?? todayIsoDate();
const date = taskActionDate(input);
const task = (await buildCuratorTasks(input.userId, date)).find((item) => item.id === input.taskId);
if (!task) throw new Error("curator_task_not_found");
if (!task) {
const event = await emitCuratorEvent({
userId: input.userId,
type: "curator.task.started",
payload: { taskId: input.taskId, date, recoveredDeepLink: true },
});
return { task: { id: input.taskId, date, status: "started" as const }, eventId: event.id };
}
const event = await emitCuratorEvent({
userId: input.userId,
type: "curator.task.started",
@@ -101,7 +131,7 @@ export const curatorService = {
},
async prepareTaskHandoff(input: { userId: string; taskId: string; date?: string }) {
const date = input.date ?? todayIsoDate();
const date = taskActionDate(input);
const task = (await buildCuratorTasks(input.userId, date)).find((item) => item.id === input.taskId);
if (!task) throw new Error("curator_task_not_found");
if (task.serviceId) return prepareHandoffForTask(input.userId, task, task.serviceId);
@@ -109,11 +139,21 @@ export const curatorService = {
},
async completeTask(input: { userId: string; taskId: string; date?: string; reason?: string }) {
const date = input.date ?? todayIsoDate();
const date = taskActionDate(input);
const task = (await buildCuratorTasks(input.userId, date)).find((item) => item.id === input.taskId);
if (!task) throw new Error("curator_task_not_found");
const reason = input.reason ?? "subtasks_completed";
const allowDirectServiceCompletion = task.serviceId === "qscore-service" && reason === "qscore_review_opened";
if (!task && reason === "matchmaking_matches_reviewed") {
const event = await emitCuratorEvent({
userId: input.userId,
type: "curator.task.completed",
payload: { taskId: input.taskId, date, reason, recoveredDeepLink: true },
});
return { task: { id: input.taskId, date, status: "completed" as const }, eventId: event.id };
}
if (!task) throw new Error("curator_task_not_found");
const allowDirectServiceCompletion =
(task.serviceId === "qscore-service" && reason === "qscore_review_opened") ||
(task.serviceId === "matchmaking-service" && reason === "matchmaking_matches_reviewed");
if (task.serviceId && !allowDirectServiceCompletion) {
throw new Error("curator_service_task_requires_service_event");
}

View File

@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import type { CuratorIcpPlaybook } from "./curator-icp-playbooks.js";
import type { CuratorIcpPlaybook } from "./icp-registry.js";
import type { CuratorUserContext } from "./curator-user-context.js";
export const CURATOR_PROMPT_VERSION = "service-curation-v1";

View File

@@ -4,6 +4,8 @@ import { createClient, type Client } from "rivetkit/client";
import { requireUser, type AuthContext } from "../../auth/clerk.js";
import { config } from "../../config.js";
import type { Registry } from "../../actors/registry.js";
import { curatorService } from "./curator-actor.js";
import { buildCuratorSprint, buildCuratorSprintReport, buildCuratorTasks } from "./curator-store.js";
let _client: Client<Registry> | null = null;
function getClient(): Client<Registry> {
@@ -25,7 +27,7 @@ const chatSchema = z.object({
const curationPreviewSchema = z.object({
startDate: z.string().optional(),
icpId: z.enum(["student_recent_grad", "intern", "fresher_early_professional", "experienced_professional"]).optional(),
icpId: z.enum(["student_recent_grad", "intern", "fresher_early_professional", "experienced_professional", "freelancer", "founder", "enterprise"]).optional(),
goals: z.array(z.string()).optional(),
userContext: z.record(z.unknown()).optional(),
});
@@ -56,18 +58,51 @@ export function v1CuratorRoutes() {
app.get("/today", async (c) => {
const userId = c.get("userId");
return c.json(await getCuratorActor(userId).getToday({ userId, date: c.req.query("date") }));
return c.json({ tasks: await buildCuratorTasks(userId, c.req.query("date")) });
});
app.get("/sprint", async (c) => {
const userId = c.get("userId");
return c.json(await getCuratorActor(userId).getSprint({ userId, date: c.req.query("date") }));
const icpQuery = c.req.query("icpId");
const dayQuery = c.req.query("dayIndex");
const icpId = curationPreviewSchema.shape.icpId.safeParse(icpQuery).success ? icpQuery as NonNullable<z.infer<typeof curationPreviewSchema>["icpId"]> : undefined;
const dayIndex = dayQuery ? Number.parseInt(dayQuery, 10) : undefined;
return c.json(await buildCuratorSprint(userId, c.req.query("date"), {
icpId,
dayIndex: Number.isFinite(dayIndex) ? dayIndex : undefined,
}));
});
app.get("/sprint/report", async (c) => {
const userId = c.get("userId");
const icpQuery = c.req.query("icpId");
const dayQuery = c.req.query("dayIndex");
const icpId = curationPreviewSchema.shape.icpId.safeParse(icpQuery).success ? icpQuery as NonNullable<z.infer<typeof curationPreviewSchema>["icpId"]> : undefined;
const dayIndex = dayQuery ? Number.parseInt(dayQuery, 10) : undefined;
return c.json(await buildCuratorSprintReport(userId, c.req.query("date"), {
icpId,
dayIndex: Number.isFinite(dayIndex) ? dayIndex : undefined,
}));
});
app.post("/sprint/report/generate", async (c) => {
const userId = c.get("userId");
const body = z.object({
date: z.string().optional(),
icpId: curationPreviewSchema.shape.icpId.optional(),
dayIndex: z.number().int().min(1).max(30).optional(),
}).parse(await c.req.json().catch(() => ({})));
return c.json(await buildCuratorSprintReport(userId, body.date, {
icpId: body.icpId,
dayIndex: body.dayIndex,
save: true,
}));
});
app.post("/curation/preview", async (c) => {
const userId = c.get("userId");
const body = curationPreviewSchema.parse(await c.req.json().catch(() => ({})));
return c.json(await getCuratorActor(userId).previewCuration({ userId, ...body }));
return c.json(await curatorService.previewCuration({ userId, ...body }));
});
app.post("/onboarding/run", async (c) => {

File diff suppressed because it is too large Load Diff

View File

@@ -499,7 +499,7 @@ export function buildCuratorTools(ctx: { userId: string; date: string; conversat
prepare_qscore_review: tool({
description: "Disabled for curator task handoffs; QScore is read-only for dashboard scoring and should not be assigned as a curator task.",
inputSchema: z.object({ taskId: z.string().optional() }),
execute: async () => ({ error: "qscore_curator_handoff_disabled", replacementServices: ["assessment-service", "interview-service", "roleplay-service", "resume-service", "matchmaking-service", "courses-service", "social-branding-service"] }),
execute: async () => ({ error: "qscore_curator_handoff_disabled", replacementServices: ["interview-service", "roleplay-service", "resume-service", "matchmaking-service", "courses-service"] }),
}),
emit_curator_event: tool({

View File

@@ -10,6 +10,8 @@ export const curatorServiceIdSchema = z.enum([
"qscore-service",
"social-branding-service",
"matchmaking-service",
"expert-service",
"events-service",
]);
export type CuratorServiceId = z.infer<typeof curatorServiceIdSchema>;
@@ -75,6 +77,7 @@ export const curatorPlanDaySchema = z.object({
weekTheme: z.string(),
weekSummary: z.string(),
focus: z.string().optional(),
outcome: z.string().optional(),
plannedServices: z.array(curatorServiceIdSchema).max(4).default([]),
generationStatus: z.enum(["seeded", "generated", "adapted"]).default("seeded"),
adaptationReason: z.string().optional(),
@@ -142,6 +145,48 @@ export const curatorSprintResponseSchema = z.object({
source: z.literal("curator-v1"),
});
export const curatorKpiEvidenceSchema = z.object({
serviceId: curatorServiceIdSchema.optional(),
taskId: z.string().optional(),
eventId: z.string().optional(),
status: curatorTaskStatusSchema.optional(),
});
export const curatorKpiReportItemSchema = z.object({
id: z.string(),
category: z.string(),
label: z.string(),
met: z.boolean(),
evidence: z.array(curatorKpiEvidenceSchema),
});
export const curatorOutcomeReportItemSchema = z.object({
id: z.string(),
text: z.string(),
met: z.boolean(),
requiredKpiIds: z.array(z.string()),
});
export const curatorSprintReportSchema = z.object({
userId: z.string(),
icpId: z.string(),
icpLabel: z.string(),
sprintId: z.string(),
generatedAt: z.string(),
saved: z.boolean().default(false),
eligible: z.boolean(),
eligibilityReason: z.string(),
days: z.array(z.object({
dayIndex: z.number().int().min(1),
title: z.string(),
outcome: z.string(),
completedCount: z.number().int().min(0),
totalCount: z.number().int().min(0),
})),
kpis: z.array(curatorKpiReportItemSchema),
outcomes: z.array(curatorOutcomeReportItemSchema),
});
export type CuratorTask = z.infer<typeof curatorTaskSchema>;
export type CuratorPlanDay = z.infer<typeof curatorPlanDaySchema>;
export type CuratorWeek = z.infer<typeof curatorWeekSchema>;
@@ -149,6 +194,10 @@ export type CuratorPlan = z.infer<typeof curatorPlanSchema>;
export type CuratorStreak = z.infer<typeof curatorStreakSchema>;
export type CuratorImprovementSignal = z.infer<typeof curatorImprovementSignalSchema>;
export type CuratorSprintResponse = z.infer<typeof curatorSprintResponseSchema>;
export type CuratorKpiEvidence = z.infer<typeof curatorKpiEvidenceSchema>;
export type CuratorKpiReportItem = z.infer<typeof curatorKpiReportItemSchema>;
export type CuratorOutcomeReportItem = z.infer<typeof curatorOutcomeReportItemSchema>;
export type CuratorSprintReport = z.infer<typeof curatorSprintReportSchema>;
export type CuratorTodayResponse = {
date: string;

View File

@@ -4,7 +4,10 @@ export type CuratorIcpId =
| "student_recent_grad"
| "intern"
| "fresher_early_professional"
| "experienced_professional";
| "experienced_professional"
| "freelancer"
| "founder"
| "enterprise";
export type CuratorPlaybookAction = {
taskType: CuratorTaskType;
@@ -81,6 +84,45 @@ export const CURATOR_ICP_PLAYBOOKS: Record<CuratorIcpId, CuratorIcpPlaybook> = {
play("practice", "roleplay-service", "negotiation and pushback", "Practice compensation, scope, promotion, and objection conversations.", "roleplay builder", ["negotiation", "pushback"]),
],
},
freelancer: {
id: "freelancer",
label: "Freelancer / Gig User",
sprintTheme: "Freelancer Business Sprint",
goal: "Build a stronger freelancer profile, portfolio proof, client conversation readiness, and business learning momentum.",
stageLabels: ["Brand + Portfolio", "Package Value", "Trust + Cases", "Client Pipeline", "Scale Forward"],
serviceActions: [
play("proof", "resume-service", "portfolio proof", "Package portfolio, services, case studies, and proposal assets.", "resume workspace", ["portfolio", "proposal"]),
play("practice", "roleplay-service", "client conversations", "Practice discovery, pricing, objections, and negotiation.", "roleplay builder", ["client discovery", "negotiation"]),
play("practice", "interview-service", "client pitch", "Practice concise pitch and client-facing presentation.", "interview preview", ["client pitch", "presentation"]),
play("measurement", "courses-service", "business learning", "Complete practical learning for sales, pricing, and marketing.", "courses", ["business sprint", "learning"]),
],
},
founder: {
id: "founder",
label: "Founder / Solopreneur",
sprintTheme: "Founder Acceleration Sprint",
goal: "Build founder proof, pitch readiness, customer conversation practice, and focused learning momentum.",
stageLabels: ["Vision + Proof", "Validate Idea", "Credibility", "Find Market", "Launch Forward"],
serviceActions: [
play("proof", "resume-service", "founder proof", "Create founder story, venture overview, pitch assets, and product proof.", "resume workspace", ["founder story", "pitch assets"]),
play("practice", "interview-service", "founder pitch", "Practice investor, customer, and media-style pitch delivery.", "interview preview", ["founder pitch", "investor"]),
play("practice", "roleplay-service", "customer discovery", "Simulate customer, sales, partnership, and negotiation conversations.", "roleplay builder", ["customer discovery", "sales"]),
play("measurement", "courses-service", "founder learning", "Complete focused founder learning for validation, growth, or fundraising.", "courses", ["founder learning", "validation"]),
],
},
enterprise: {
id: "enterprise",
label: "Enterprise",
sprintTheme: "Enterprise Capability Sprint",
goal: "Activate workforce capability proof, leadership practice, hiring calibration, and learning pathways.",
stageLabels: ["Workforce Baseline", "Talent Measurement", "Capability Building", "Hiring Excellence", "Future Ready"],
serviceActions: [
play("proof", "resume-service", "capability documentation", "Generate competency frameworks, hiring profiles, and workforce documentation.", "resume workspace", ["competency", "hiring profiles"]),
play("practice", "interview-service", "interview calibration", "Standardize interview questions, rubrics, and hiring simulations.", "interview preview", ["interview calibration", "rubrics"]),
play("practice", "roleplay-service", "leadership simulation", "Practice leadership, coaching, conflict, and stakeholder conversations.", "roleplay builder", ["leadership", "coaching"]),
play("measurement", "courses-service", "learning pathways", "Assign or complete focused capability learning pathways.", "courses", ["learning pathways", "capability"]),
],
},
};
export function isCuratorIcpId(value: string): value is CuratorIcpId {

View File

@@ -0,0 +1,150 @@
import type { CuratorIcpId } from "./icp-registry.js";
import type { CuratorServiceId, CuratorTaskType } from "./curator-types.js";
export type CuratorKpiDefinition = {
id: string;
icpId: CuratorIcpId;
category: string;
label: string;
requiredServiceIds: CuratorServiceId[];
requiredTaskTypes?: CuratorTaskType[];
acceptableCompletionEvents: string[];
outcomeIds: string[];
};
type KpiSeed = {
key: string;
category: string;
label: string;
requiredServiceIds: CuratorServiceId[];
requiredTaskTypes?: CuratorTaskType[];
acceptableCompletionEvents?: string[];
};
const DEFAULT_COMPLETION_EVENTS = [
"curator.task.completed",
"service.completed",
"qscore.updated",
"qscore.signal.projected",
"resume.analysis.completed",
"resume.parsed",
"resume.updated",
"resume.exported",
"interview.session.configured",
"interview.session.completed",
"interview.feedback.generated",
"roleplay.scenario.configured",
"roleplay.scenario.completed",
"roleplay.feedback.generated",
"assessment.completed",
"course.completed",
"course.lesson.completed",
"matchmaking.matches.generated",
"matchmaking.feed.viewed",
"matchmaking.match.saved",
"matchmaking.application.completed",
"brand.post.exported",
"brand.profile.updated",
];
const COMMON_KPI_KEYS = {
branding: ["social-branding-service"] as CuratorServiceId[],
intelligence: ["qscore-service", "assessment-service"] as CuratorServiceId[],
assets: ["resume-service", "cover-letter-service"] as CuratorServiceId[],
interview: ["interview-service", "roleplay-service"] as CuratorServiceId[],
learning: ["courses-service"] as CuratorServiceId[],
networking: ["expert-service", "events-service"] as CuratorServiceId[],
opportunity: ["matchmaking-service"] as CuratorServiceId[],
strategy: ["resume-service"] as CuratorServiceId[],
};
function kpis(icpId: CuratorIcpId, seeds: KpiSeed[]): CuratorKpiDefinition[] {
return seeds.map((seed) => ({
id: `${icpId}:${seed.key}`,
icpId,
category: seed.category,
label: seed.label,
requiredServiceIds: seed.requiredServiceIds,
requiredTaskTypes: seed.requiredTaskTypes,
acceptableCompletionEvents: seed.acceptableCompletionEvents ?? DEFAULT_COMPLETION_EVENTS,
outcomeIds: [`${icpId}:${seed.key}`],
}));
}
export const CURATOR_KPI_REGISTRY: Record<CuratorIcpId, CuratorKpiDefinition[]> = {
student_recent_grad: kpis("student_recent_grad", [
{ key: "branding", category: "Professional Branding", label: "GrowQR profile and LinkedIn profile completed", requiredServiceIds: COMMON_KPI_KEYS.branding },
{ key: "intelligence", category: "Career Intelligence", label: "QX Score baseline established and Dashboard activated", requiredServiceIds: COMMON_KPI_KEYS.intelligence },
{ key: "assets", category: "Career Assets", label: "Resume, cover letter and academic portfolio completed", requiredServiceIds: COMMON_KPI_KEYS.assets },
{ key: "interview", category: "Interview Readiness", label: "Mock interview and communication practice completed", requiredServiceIds: COMMON_KPI_KEYS.interview },
{ key: "learning", category: "Learning", label: "One AI-recommended course completed", requiredServiceIds: COMMON_KPI_KEYS.learning },
{ key: "networking", category: "Networking", label: "One mentor session and one GrowQR event attended", requiredServiceIds: COMMON_KPI_KEYS.networking },
{ key: "opportunity", category: "Opportunity Pipeline", label: "AI-recommended internships or graduate roles reviewed and at least three opportunities saved or applied for", requiredServiceIds: COMMON_KPI_KEYS.opportunity },
{ key: "strategy", category: "Career Strategy", label: "AI-generated Career Growth Blueprint completed", requiredServiceIds: COMMON_KPI_KEYS.strategy, requiredTaskTypes: ["proof"] },
]),
intern: kpis("intern", [
{ key: "branding", category: "Professional Branding", label: "GrowQR profile and LinkedIn profile completed", requiredServiceIds: COMMON_KPI_KEYS.branding },
{ key: "intelligence", category: "Career Intelligence", label: "QX Score baseline established and Dashboard activated", requiredServiceIds: COMMON_KPI_KEYS.intelligence },
{ key: "assets", category: "Career Assets", label: "Resume, cover letter and project portfolio completed", requiredServiceIds: COMMON_KPI_KEYS.assets },
{ key: "interview", category: "Interview Readiness", label: "HR interview and workplace simulation completed", requiredServiceIds: COMMON_KPI_KEYS.interview },
{ key: "learning", category: "Learning", label: "One personalized learning pathway completed", requiredServiceIds: COMMON_KPI_KEYS.learning },
{ key: "networking", category: "Networking", label: "One mentor session and one GrowQR event attended", requiredServiceIds: COMMON_KPI_KEYS.networking },
{ key: "opportunity", category: "Opportunity Pipeline", label: "AI-matched roles reviewed and at least three applications submitted", requiredServiceIds: COMMON_KPI_KEYS.opportunity },
{ key: "strategy", category: "Career Strategy", label: "AI-generated Career Advancement Blueprint completed", requiredServiceIds: COMMON_KPI_KEYS.strategy, requiredTaskTypes: ["proof"] },
]),
fresher_early_professional: kpis("fresher_early_professional", [
{ key: "branding", category: "Professional Branding", label: "GrowQR profile and LinkedIn profile completed", requiredServiceIds: COMMON_KPI_KEYS.branding },
{ key: "intelligence", category: "Career Intelligence", label: "QX Score baseline established and Dashboard activated", requiredServiceIds: COMMON_KPI_KEYS.intelligence },
{ key: "assets", category: "Career Assets", label: "Resume, cover letter and project portfolio completed", requiredServiceIds: COMMON_KPI_KEYS.assets },
{ key: "interview", category: "Interview Readiness", label: "Mock interview and workplace simulation completed", requiredServiceIds: COMMON_KPI_KEYS.interview },
{ key: "learning", category: "Learning", label: "One recommended course completed", requiredServiceIds: COMMON_KPI_KEYS.learning },
{ key: "networking", category: "Networking", label: "One mentor session and one career event attended", requiredServiceIds: COMMON_KPI_KEYS.networking },
{ key: "opportunity", category: "Opportunity Pipeline", label: "AI-recommended jobs reviewed and at least three applications submitted", requiredServiceIds: COMMON_KPI_KEYS.opportunity },
{ key: "strategy", category: "Career Strategy", label: "AI-generated Career Growth Blueprint completed", requiredServiceIds: COMMON_KPI_KEYS.strategy, requiredTaskTypes: ["proof"] },
]),
experienced_professional: kpis("experienced_professional", [
{ key: "branding", category: "Executive Branding", label: "Executive profile, leadership bio and thought leadership completed", requiredServiceIds: COMMON_KPI_KEYS.branding },
{ key: "intelligence", category: "Leadership Intelligence", label: "Executive QX Score baseline and Dashboard activated", requiredServiceIds: COMMON_KPI_KEYS.intelligence },
{ key: "assets", category: "Career Assets", label: "Executive resume, leadership portfolio and achievement documentation completed", requiredServiceIds: COMMON_KPI_KEYS.assets },
{ key: "interview", category: "Leadership Readiness", label: "Executive interview and boardroom simulations completed", requiredServiceIds: COMMON_KPI_KEYS.interview },
{ key: "learning", category: "Learning", label: "Executive leadership course completed", requiredServiceIds: COMMON_KPI_KEYS.learning },
{ key: "networking", category: "Networking", label: "One executive mentor session and one leadership event attended", requiredServiceIds: COMMON_KPI_KEYS.networking },
{ key: "opportunity", category: "Opportunity Pipeline", label: "Senior leadership and advisory opportunities reviewed", requiredServiceIds: COMMON_KPI_KEYS.opportunity },
{ key: "strategy", category: "Growth Strategy", label: "AI-generated Executive Leadership Blueprint completed", requiredServiceIds: COMMON_KPI_KEYS.strategy, requiredTaskTypes: ["proof"] },
]),
freelancer: kpis("freelancer", [
{ key: "branding", category: "Personal Branding", label: "Freelancer profile and service page completed", requiredServiceIds: COMMON_KPI_KEYS.branding },
{ key: "intelligence", category: "Business Intelligence", label: "Freelancer QX Score baseline and Dashboard activated", requiredServiceIds: COMMON_KPI_KEYS.intelligence },
{ key: "assets", category: "Portfolio & Proof", label: "Portfolio, case study and proposal templates created", requiredServiceIds: COMMON_KPI_KEYS.assets },
{ key: "interview", category: "Client Readiness", label: "Client pitch and negotiation simulations completed", requiredServiceIds: COMMON_KPI_KEYS.interview },
{ key: "learning", category: "Learning", label: "One freelancer business course completed", requiredServiceIds: COMMON_KPI_KEYS.learning },
{ key: "networking", category: "Networking", label: "One mentor consultation and one networking event attended", requiredServiceIds: COMMON_KPI_KEYS.networking },
{ key: "opportunity", category: "Opportunity Pipeline", label: "AI-recommended client opportunities reviewed and shortlisted", requiredServiceIds: COMMON_KPI_KEYS.opportunity },
{ key: "strategy", category: "Growth Strategy", label: "AI-generated Client Growth Blueprint completed", requiredServiceIds: COMMON_KPI_KEYS.strategy, requiredTaskTypes: ["proof"] },
]),
founder: kpis("founder", [
{ key: "branding", category: "Founder Brand", label: "Founder profile and startup page completed", requiredServiceIds: COMMON_KPI_KEYS.branding },
{ key: "intelligence", category: "Venture Intelligence", label: "Founder QX Score baseline and Dashboard activated", requiredServiceIds: COMMON_KPI_KEYS.intelligence },
{ key: "assets", category: "Business Proof", label: "Founder story, pitch deck and MVP portfolio completed", requiredServiceIds: COMMON_KPI_KEYS.assets },
{ key: "validation", category: "Customer Validation", label: "Customer discovery conversations completed", requiredServiceIds: ["roleplay-service"] },
{ key: "interview", category: "Investor Readiness", label: "Investor pitch practiced and refined", requiredServiceIds: ["interview-service", "roleplay-service"] },
{ key: "learning", category: "Learning", label: "Founder capability course completed", requiredServiceIds: COMMON_KPI_KEYS.learning },
{ key: "networking", category: "Networking", label: "One mentor session and one founder event attended", requiredServiceIds: COMMON_KPI_KEYS.networking },
{ key: "opportunity", category: "Opportunity Pipeline", label: "Customer, investor and partnership opportunities identified", requiredServiceIds: COMMON_KPI_KEYS.opportunity },
{ key: "strategy", category: "Growth Strategy", label: "AI-generated Startup Growth Blueprint completed", requiredServiceIds: COMMON_KPI_KEYS.strategy, requiredTaskTypes: ["proof"] },
]),
enterprise: kpis("enterprise", [
{ key: "branding", category: "Employer Branding", label: "Employer profile updated and employer branding campaign launched", requiredServiceIds: COMMON_KPI_KEYS.branding },
{ key: "intelligence", category: "Workforce Intelligence", label: "Enterprise QX Score baseline and capability dashboard activated", requiredServiceIds: COMMON_KPI_KEYS.intelligence },
{ key: "capability", category: "Capability Development", label: "Competency framework, learning paths and succession plans created", requiredServiceIds: ["resume-service", "courses-service"] },
{ key: "hiring", category: "Hiring Excellence", label: "Standardized interview process and AI-assisted hiring toolkit deployed", requiredServiceIds: ["resume-service", "roleplay-service"] },
{ key: "leadership", category: "Leadership Development", label: "Leadership simulations and coaching sessions completed", requiredServiceIds: ["roleplay-service"] },
{ key: "learning", category: "Learning", label: "Enterprise learning journeys assigned to teams", requiredServiceIds: COMMON_KPI_KEYS.learning },
{ key: "expert", category: "Expert Engagement", label: "At least one expert consultation and one enterprise event attended", requiredServiceIds: COMMON_KPI_KEYS.networking },
{ key: "strategy", category: "Strategic Output", label: "Workforce Transformation Blueprint generated", requiredServiceIds: COMMON_KPI_KEYS.strategy, requiredTaskTypes: ["proof"] },
]),
};
export function kpiDefinitionsForIcp(icpId: CuratorIcpId) {
return CURATOR_KPI_REGISTRY[icpId] ?? CURATOR_KPI_REGISTRY.fresher_early_professional;
}

View File

@@ -0,0 +1,101 @@
import type { CuratorIcpId } from "./icp-registry.js";
export type CuratorOutcomeDefinition = {
id: string;
icpId: CuratorIcpId;
text: string;
requiredKpiIds: string[];
};
type OutcomeSeed = {
key: string;
text: string;
requiredKpiKeys?: string[];
};
function outcomes(icpId: CuratorIcpId, seeds: OutcomeSeed[]): CuratorOutcomeDefinition[] {
return seeds.map((seed) => ({
id: `${icpId}:${seed.key}`,
icpId,
text: seed.text,
requiredKpiIds: (seed.requiredKpiKeys ?? [seed.key]).map((key) => `${icpId}:${key}`),
}));
}
export const CURATOR_OUTCOME_REPORT_REGISTRY: Record<CuratorIcpId, CuratorOutcomeDefinition[]> = {
student_recent_grad: outcomes("student_recent_grad", [
{ key: "branding", text: "Professional GrowQR profile showcasing academic achievements and career aspirations." },
{ key: "intelligence", text: "Measurable QX Score with clear insights into career readiness." },
{ key: "assets", text: "ATS-ready resume, personalized cover letter and academic project portfolio." },
{ key: "interview", text: "Increased confidence through AI-powered interview practice and workplace simulations." },
{ key: "learning", text: "Personalized learning recommendations based on identified skill gaps." },
{ key: "networking", text: "Guidance from mentors and access to professional networking opportunities." },
{ key: "opportunity", text: "Curated list of internships, graduate programs and entry-level roles aligned with career goals." },
{ key: "strategy", text: "AI-generated Career Growth Blueprint outlining the next 30-90 days of development and application strategy." },
]),
intern: outcomes("intern", [
{ key: "branding", text: "Polished professional profile highlighting internship and early work experience." },
{ key: "intelligence", text: "Measurable QX Score with actionable career intelligence." },
{ key: "assets", text: "ATS-optimized resume, tailored cover letter and project portfolio." },
{ key: "interview", text: "Improved confidence through AI-powered interview coaching and workplace roleplays." },
{ key: "learning", text: "Personalized learning recommendations to close career-critical skill gaps." },
{ key: "networking", text: "Connections with mentors, recruiters and industry professionals." },
{ key: "opportunity", text: "Curated pipeline of relevant early-career opportunities." },
{ key: "strategy", text: "AI-generated Career Advancement Blueprint with clear next steps for securing interviews and accelerating professional growth." },
]),
fresher_early_professional: outcomes("fresher_early_professional", [
{ key: "branding", text: "Complete professional profile and stronger personal brand." },
{ key: "intelligence", text: "Measurable QX Score with clear employability insights." },
{ key: "assets", text: "ATS-ready resume, tailored cover letter and project portfolio." },
{ key: "interview", text: "Improved interview confidence through AI coaching and roleplay." },
{ key: "learning", text: "Personalized learning recommendations based on skill gaps." },
{ key: "networking", text: "Guidance from industry experts and professional mentors." },
{ key: "opportunity", text: "Active pipeline of job opportunities and recruiter connections." },
{ key: "strategy", text: "AI-generated Career Growth Blueprint with practical next steps for securing interviews and job offers." },
]),
experienced_professional: outcomes("experienced_professional", [
{ key: "branding", text: "Compelling executive profile and leadership brand." },
{ key: "intelligence", text: "Measurable Executive QX Score with leadership intelligence insights." },
{ key: "assets", text: "Polished executive resume, leadership portfolio and business impact documentation." },
{ key: "interview", text: "Increased confidence in executive interviews, negotiations and boardroom communication." },
{ key: "learning", text: "Personalized leadership learning recommendations." },
{ key: "networking", text: "Strategic relationships with mentors, executive communities and industry experts." },
{ key: "opportunity", text: "Curated pipeline of senior leadership, consulting and board opportunities." },
{ key: "strategy", text: "AI-generated Executive Leadership Growth Blueprint outlining the next phase of career advancement." },
]),
freelancer: outcomes("freelancer", [
{ key: "branding", text: "Compelling freelancer profile and professional brand." },
{ key: "intelligence", text: "Measurable Freelancer QX Score with business readiness insights." },
{ key: "assets", text: "Polished portfolio, proposal templates and client case studies." },
{ key: "interview", text: "Improved confidence in client discovery, pitching and negotiations." },
{ key: "learning", text: "Personalized learning recommendations for growing a freelance business." },
{ key: "networking", text: "Stronger networking through mentors and GrowQR community events." },
{ key: "opportunity", text: "Active pipeline of freelance projects and collaboration opportunities." },
{ key: "strategy", text: "AI-generated Business Growth Blueprint with actionable recommendations for attracting better clients and increasing recurring revenue." },
]),
founder: outcomes("founder", [
{ key: "branding", text: "Compelling founder profile and personal brand." },
{ key: "intelligence", text: "Measurable Founder QX Score and startup readiness dashboard." },
{ key: "assets", text: "Polished pitch deck, founder story and business portfolio." },
{ key: "validation", text: "Customer discovery insights and validated value proposition." },
{ key: "interview", text: "AI-coached investor and customer pitch confidence." },
{ key: "learning", text: "Personalized learning recommendations for business growth." },
{ key: "networking", text: "Connections with mentors, founders and startup communities." },
{ key: "opportunity", text: "Structured opportunity pipeline for customers, investors and partners." },
{ key: "strategy", text: "AI-generated Startup Growth Blueprint outlining the next 90 days of execution." },
]),
enterprise: outcomes("enterprise", [
{ key: "intelligence", text: "Measurable Enterprise QX Capability Score." },
{ key: "intelligence-dashboard", text: "Live Workforce Intelligence Dashboard.", requiredKpiKeys: ["intelligence"] },
{ key: "capability", text: "AI-generated competency frameworks and hiring playbooks.", requiredKpiKeys: ["capability", "hiring"] },
{ key: "leadership", text: "Structured leadership and interview standards.", requiredKpiKeys: ["leadership", "hiring"] },
{ key: "learning", text: "Personalized learning pathways for employees." },
{ key: "branding", text: "Enhanced employer branding and visibility." },
{ key: "expert", text: "Access to expert consultations and enterprise events." },
{ key: "strategy", text: "Complete AI-powered Workforce Transformation Blueprint for future-ready talent development." },
]),
};
export function outcomeDefinitionsForIcp(icpId: CuratorIcpId) {
return CURATOR_OUTCOME_REPORT_REGISTRY[icpId] ?? CURATOR_OUTCOME_REPORT_REGISTRY.fresher_early_professional;
}

View File

@@ -0,0 +1,257 @@
import type { CuratorIcpId } from "./icp-registry.js";
import type { CuratorServiceId, CuratorTaskType } from "./curator-types.js";
export type TaskSeed = {
taskType: CuratorTaskType;
serviceId: CuratorServiceId;
title: string;
subtitle: string;
effort: string;
qxImpact: string;
cta: string;
signals: string[];
};
export type WeekTemplate = {
theme: string;
summary: string;
days: Array<{
focus?: string;
outcome?: string;
measurement: TaskSeed;
proof: TaskSeed;
practice: TaskSeed;
roleplay?: TaskSeed;
}>;
};
export type CuratorIcpTemplateSet = {
id: CuratorIcpId;
label: string;
sprintTheme: string;
goal: string;
weeks: WeekTemplate[];
};
function unavailableCta(serviceId: CuratorServiceId, fallback: string) {
if (
serviceId === "social-branding-service" ||
serviceId === "qscore-service" ||
serviceId === "assessment-service" ||
serviceId === "expert-service" ||
serviceId === "events-service"
) {
return "View service status";
}
return fallback;
}
export const CURATOR_TASK_REGISTRY: Record<CuratorIcpId, CuratorIcpTemplateSet> = {
student_recent_grad: staticIcpTemplate("student_recent_grad", "Student / Recent Grad", "7-Day Student CareerSprint", "Build a professional identity, career assets, interview confidence, and learning momentum.", [
day("Start Your Story", "Career profile established with a measurable readiness baseline.", "Claim Your Corner", "Complete your GrowQR profile with education, interests, career goals and profile photo.", "social-branding-service", "Know Your Number", "Review your QX Score, Career Dashboard and complete your Career Readiness Assessment.", "qscore-service", "Resume Rescue", "Create your first AI-powered resume using your academic achievements.", "resume-service", "Pitch Perfect", "Record a 60-second self-introduction for recruiters and mentors.", "interview-service"),
day("Discover Your Strengths", "Stronger professional identity and clearer career direction.", "Headline Hero", "Write a compelling professional headline and About section.", "social-branding-service", "Skill Gap Radar", "Review assessment insights and identify your top three improvement areas.", "assessment-service", "Project Spotlight", "Add one academic project, internship or extracurricular achievement.", "resume-service", "Manager Mode", "Practice introducing yourself in a professional workplace conversation.", "roleplay-service"),
day("Build Credibility", "Career proof strengthened with interview feedback received.", "Proof Beats Promises", "Upload certificates, awards or extracurricular achievements.", "social-branding-service", "Career Snapshot", "Review ATS readiness, profile completion and Dashboard insights.", "qscore-service", "Portfolio Power", "Create a simple academic portfolio or project showcase.", "resume-service", "Interview Replay", "Complete a mock interview and review AI-generated feedback.", "interview-service"),
day("Explore Opportunities", "Opportunity pipeline activated and application assets prepared.", "Go Visible", "Update your LinkedIn and GrowQR professional profile.", "social-branding-service", "Opportunity Radar", "Review AI-matched internships, campus placements and research opportunities.", "matchmaking-service", "Cover Letter Boost", "Generate a personalized cover letter for a selected opportunity.", "resume-service", "Group Discussion Lab", "Practice communication through an AI group discussion simulation.", "roleplay-service"),
day("Learn To Grow", "Skills improved with measurable learning progress.", "Show Your Progress", "Publish your latest certification or learning achievement.", "social-branding-service", "Growth Snapshot", "Review Dashboard, updated QX Score and weekly readiness improvements.", "qscore-service", "Learning Sprint", "Complete one AI-recommended course to close a skill gap.", "courses-service", "Communication Lab", "Practice workplace communication and presentation skills.", "roleplay-service"),
day("Grow Your Network", "Professional network expanded with expert guidance.", "Network Smart", "Connect with alumni, mentors, faculty members and professionals.", "social-branding-service", "Career Intelligence", "Review networking suggestions and recruiter recommendations.", "matchmaking-service", "Expert Connect", "Book a mentor session for resume review or career guidance.", "expert-service", "Career Connect Live", "Attend a GrowQR workshop, employer webinar or networking event.", "events-service"),
day("Ready For Tomorrow", "Career-ready profile completed with a personalized action plan for internships and first-job opportunities.", "Ready To Shine", "Share your weekly achievements and future career aspirations.", "social-branding-service", "Career Scorecard", "Review Dashboard, updated QX Score and weekly progress report.", "qscore-service", "Career Blueprint", "Generate your AI-powered Career Growth Blueprint and apply for your best-matched opportunity.", "resume-service", "Mock Finale", "Complete a full AI interview simulation for your dream internship or first job.", "interview-service"),
]),
intern: staticIcpTemplate("intern", "Intern / Early Career", "7-Day Early CareerSprint", "Convert internship work into career proof, stronger interviews, workplace confidence, and learning momentum.", [
day("Own Your Journey", "Professional profile established with career readiness baseline.", "Claim Your Corner", "Complete your GrowQR profile with internship experience, skills and career goals.", "social-branding-service", "Know Your Number", "Review your QX Score, Career Dashboard and complete your Career Readiness Assessment.", "qscore-service", "Resume Reloaded", "Build an AI-optimized resume tailored to your target role.", "resume-service", "Pitch Perfect", "Record a 60-second professional introduction highlighting your internship experience.", "interview-service"),
day("Show Your Growth", "Internship experience converted into impactful career proof.", "Headline Hero", "Write a compelling professional headline and update your About section.", "social-branding-service", "Career Snapshot", "Review recruiter visibility, ATS readiness and internship-to-job readiness.", "qscore-service", "Achievement Spotlight", "Transform internship tasks into measurable achievements.", "resume-service", "Recruiter Connect", "Practice an HR screening interview.", "roleplay-service"),
day("Build Credibility", "Professional credibility enhanced through measurable work examples.", "Proof Beats Promises", "Publish one internship project, certification or professional achievement.", "social-branding-service", "Skill Gap Radar", "Review competency assessment and identify improvement areas.", "assessment-service", "Project Showcase", "Document a project demonstrating business impact.", "resume-service", "Manager Mode", "Practice workplace communication with a reporting manager.", "roleplay-service"),
day("Find Better Opportunities", "Career documents optimized and opportunity pipeline activated.", "Go Visible", "Strengthen your LinkedIn and GrowQR professional profile.", "social-branding-service", "Opportunity Radar", "Review AI-recommended early-career roles and opportunities.", "matchmaking-service", "Application Pack", "Generate a customized resume and cover letter for a selected role.", "resume-service", "Workplace Conversations", "Practice collaboration and workplace scenarios.", "roleplay-service"),
day("Strengthen Your Skills", "Skill gaps addressed and interview confidence improved.", "Build Authority", "Share a certification, learning milestone or project update.", "social-branding-service", "Growth Snapshot", "Review Dashboard insights, QX Score movement and interview readiness.", "qscore-service", "Learning Sprint", "Complete an AI-recommended course aligned with your career goals.", "courses-service", "Interview Replay", "Complete a mock interview and implement AI feedback.", "interview-service"),
day("Expand Your Network", "Professional network expanded with expert guidance and recruiter exposure.", "Network Smart", "Connect with recruiters, mentors, managers and industry professionals.", "social-branding-service", "Career Intelligence", "Review networking opportunities and recruiter engagement.", "matchmaking-service", "Expert Connect", "Book an expert session for career guidance or resume review.", "expert-service", "Career Connect Live", "Attend a GrowQR networking session, employer webinar or career event.", "events-service"),
day("Step Into Opportunity", "Career-ready profile completed with active applications and a personalized growth roadmap.", "Ready To Rise", "Publish your weekly achievements and career aspirations.", "social-branding-service", "Career Scorecard", "Review Dashboard, updated QX Score and weekly employability report.", "qscore-service", "Career Blueprint", "Generate your AI-powered Career Advancement Blueprint and submit applications to your top-matched roles.", "resume-service", "Mock Finale", "Complete a full interview simulation for your preferred job role.", "interview-service"),
]),
fresher_early_professional: staticIcpTemplate("fresher_early_professional", "Fresher / Active Job Seeker", "7-Day Job Seeker CareerSprint", "Improve employability, recruiter visibility, interview readiness, and application quality.", [
day("Start Strong", "Professional profile completed with a measurable employability baseline.", "Claim Your Corner", "Complete your GrowQR profile with education, skills, career goals and professional photo.", "social-branding-service", "Know Your Number", "Review your QX Score, Career Dashboard and complete your Career Readiness Assessment.", "qscore-service", "Resume Rescue", "Build your first AI-optimized resume for your preferred job role.", "resume-service", "Pitch Perfect", "Record a 60-second self-introduction for recruiters.", "interview-service"),
day("Build Visibility", "Resume optimized for recruiter searches and stronger first impressions.", "Headline Hero", "Write an attention-grabbing professional headline and About section.", "social-branding-service", "Career Snapshot", "Review profile completion, ATS readiness and recruiter visibility.", "qscore-service", "Keyword Lift", "Optimize your resume with AI-recommended keywords.", "resume-service", "Recruiter Connect", "Practice a recruiter screening conversation.", "roleplay-service"),
day("Show Your Potential", "Stronger proof of capability and improved interview awareness.", "Proof Beats Promises", "Publish an academic project, internship or achievement.", "social-branding-service", "Skill Gap Radar", "Review your capability assessment and identify priority skills.", "assessment-service", "Project Spotlight", "Add one detailed project with measurable outcomes.", "resume-service", "Interview Replay", "Complete a behavioural mock interview and review AI feedback.", "interview-service"),
day("Find Better Opportunities", "Career documents prepared and opportunity pipeline activated.", "Go Visible", "Update your LinkedIn and GrowQR professional presence.", "social-branding-service", "Opportunity Radar", "Review AI-matched jobs, internships and career opportunities.", "matchmaking-service", "Cover Letter Boost", "Generate a personalized cover letter for a target role.", "resume-service", "Manager Mode", "Practice workplace communication with an AI manager simulation.", "roleplay-service"),
day("Grow Your Skills", "Skill gaps reduced and communication confidence improved.", "Build Authority", "Share one learning milestone or certification.", "social-branding-service", "Growth Snapshot", "Review Dashboard insights, QX Score movement and profile strength.", "qscore-service", "Learning Sprint", "Complete one AI-recommended skill course.", "courses-service", "Communication Lab", "Practice workplace communication and confidence exercises.", "roleplay-service"),
day("Expand Your Network", "Professional network expanded with expert guidance received.", "Network Smart", "Connect with recruiters, alumni and professionals in your field.", "social-branding-service", "Career Intelligence", "Review networking opportunities and recruiter recommendations.", "matchmaking-service", "Expert Connect", "Book a mentor session for resume or career guidance.", "expert-service", "Career Connect Live", "Attend a GrowQR hiring webinar or networking event.", "events-service"),
day("Launch Your Career", "Job-ready profile completed with applications submitted and clear next steps.", "Ready To Shine", "Publish your career goals and professional achievements.", "social-branding-service", "Career Scorecard", "Review Dashboard, updated QX Score and weekly progress report.", "qscore-service", "Application Ready", "Generate your AI-powered Career Action Blueprint and apply to your best-matched role.", "resume-service", "Mock Finale", "Complete a full AI interview simulation for your target role.", "interview-service"),
]),
experienced_professional: staticIcpTemplate("experienced_professional", "Experienced Professional", "7-Day Leadership CareerSprint", "Strengthen leadership proof, senior interview readiness, conversation confidence, and learning momentum.", [
day("Lead With Purpose", "Executive profile established with leadership readiness baseline.", "Own Your Executive Brand", "Complete your GrowQR executive profile, leadership bio and career vision.", "social-branding-service", "Know Your Number", "Review Executive QX Score, Dashboard and Leadership Assessment.", "qscore-service", "Executive Resume", "Create a leadership-focused resume highlighting measurable business impact.", "resume-service", "Executive Introduction", "Record a 90-second executive leadership introduction.", "interview-service"),
day("Build Executive Presence", "Executive positioning strengthened with measurable business impact.", "Thought Leader Mode", "Publish your leadership philosophy or industry insight.", "social-branding-service", "Leadership Pulse", "Review leadership strengths, competency gaps and opportunity insights.", "matchmaking-service", "Impact Portfolio", "Document major achievements, transformation projects and business outcomes.", "resume-service", "Boardroom Briefing", "Practice executive stakeholder communication.", "roleplay-service"),
day("Show Strategic Value", "Leadership capability validated with executive proof assets.", "Influence In Action", "Share a case study, article or strategic business insight.", "social-branding-service", "Capability Scan", "Review leadership capability assessment and organizational fit.", "assessment-service", "Promotion Portfolio", "Prepare evidence for promotions, executive interviews or board appointments.", "resume-service", "Executive Interview", "Complete a senior leadership mock interview.", "interview-service"),
day("Expand Your Influence", "Executive opportunities identified and leadership brand elevated.", "Network With Purpose", "Strengthen your executive network and update your professional presence.", "social-branding-service", "Opportunity Scan", "Review senior leadership roles, advisory positions and board opportunities.", "matchmaking-service", "Executive Brand Kit", "Optimize executive profile, LinkedIn and professional portfolio.", "resume-service", "Negotiation Room", "Practice salary, board and leadership negotiations.", "roleplay-service"),
day("Future-Proof Leadership", "Leadership capabilities strengthened through targeted executive learning.", "Executive Visibility", "Publish a leadership perspective or innovation story.", "social-branding-service", "Growth Snapshot", "Review Dashboard, QX trends and executive readiness analytics.", "qscore-service", "Leadership Learning", "Complete an executive leadership or AI strategy course.", "courses-service", "Crisis Leadership", "Practice managing high-pressure executive scenarios.", "roleplay-service"),
day("Build Strategic Relationships", "Expanded strategic network and leadership guidance established.", "Executive Connector", "Engage with senior professionals, mentors and industry communities.", "social-branding-service", "Market Intelligence", "Review strategic collaborations, board roles and executive opportunities.", "matchmaking-service", "Expert Connect", "Book a mentoring session with an industry leader or executive coach.", "expert-service", "Leadership Forum", "Attend an executive webinar or GrowQR leadership event.", "events-service"),
day("Lead The Future", "Executive CareerSprint completed with a measurable leadership roadmap and next-level career strategy.", "Executive Spotlight", "Publish your leadership roadmap and professional milestones.", "social-branding-service", "Leadership Scorecard", "Review Dashboard, Executive QX Score and weekly progress analytics.", "qscore-service", "Career Blueprint", "Generate an AI-powered Executive Growth & Leadership Blueprint.", "resume-service", "Boardroom Simulation", "Deliver a complete executive presentation and strategic decision simulation.", "roleplay-service"),
]),
freelancer: staticIcpTemplate("freelancer", "Freelancer / Gig User", "7-Day Freelancer CareerSprint", "Build trust, package freelance proof, practice client conversations, and improve business skills.", [
day("Build Your Brand", "Professional freelance identity established with measurable business baseline.", "Claim Your Corner", "Complete your freelancer profile, niche and service offerings.", "social-branding-service", "Know Your Number", "Review Freelancer QX Score, Dashboard and Business Readiness Assessment.", "qscore-service", "Portfolio Rescue", "Upload your best work, portfolio and service profile.", "resume-service", "Client Pitch", "Record a 60-second introduction for prospective clients.", "interview-service"),
day("Package Your Value", "Clear service positioning and improved client readiness.", "Show Your Expertise", "Publish your services and professional strengths.", "social-branding-service", "Opportunity Radar", "Review recommended freelance projects and client opportunities.", "matchmaking-service", "Service Showcase", "Create attractive service packages and pricing overview.", "resume-service", "Discovery Call", "Practice your first client consultation.", "roleplay-service"),
day("Earn Trust", "Stronger credibility through real business proof.", "Proof Beats Promises", "Publish testimonials or client success stories.", "social-branding-service", "Trust Meter", "Track profile strength and credibility indicators.", "qscore-service", "Case Study Builder", "Document one successful project with measurable outcomes.", "resume-service", "Client Objection Lab", "Practice handling pricing and client objections.", "roleplay-service"),
day("Find More Clients", "Qualified client pipeline established.", "Go Visible", "Share a professional insight or portfolio update.", "social-branding-service", "Lead Finder", "Review AI-matched projects and client opportunities.", "matchmaking-service", "Proposal Builder", "Generate a professional proposal template.", "resume-service", "Sales Conversation", "Simulate a client proposal presentation.", "roleplay-service"),
day("Grow Your Business", "Business skills strengthened and pricing confidence improved.", "Build Authority", "Publish expertise through LinkedIn or GrowQR community.", "social-branding-service", "Business Snapshot", "Review Dashboard, earnings potential and pipeline analytics.", "qscore-service", "Learning Sprint", "Complete a course on sales, marketing or pricing.", "courses-service", "Negotiation Lab", "Practice pricing and contract negotiation.", "roleplay-service"),
day("Expand Your Network", "Expanded professional network and collaboration opportunities.", "Community Builder", "Connect with clients, creators and professionals.", "social-branding-service", "Opportunity Pulse", "Review collaborations, referrals and strategic matches.", "matchmaking-service", "Expert Connect", "Book a mentor or business expert consultation.", "expert-service", "Freelancer Meetup", "Attend a GrowQR networking event or webinar.", "events-service"),
day("Scale Forward", "Business growth roadmap completed with stronger client acquisition strategy.", "Trusted Professional", "Publish your weekly growth achievements and client success.", "social-branding-service", "Growth Scorecard", "Review Dashboard, Freelancer QX Score and weekly business insights.", "qscore-service", "Growth Blueprint", "Generate your AI-powered Client Growth & Business Roadmap.", "resume-service", "Premium Client Meeting", "Deliver a complete client pitch simulation.", "interview-service"),
]),
founder: staticIcpTemplate("founder", "Founder / Solopreneur", "7-Day Founder Acceleration Sprint", "Validate venture proof, strengthen founder credibility, and practice customer or investor conversations.", [
day("Own Your Vision", "Founder identity established with a measurable business baseline.", "Build In Public", "Complete your Founder Profile, business description and company branding.", "social-branding-service", "Know Your Number", "Review Founder QX Score, Dashboard and Startup Readiness Assessment.", "qscore-service", "Founder Story", "Create your founder profile, mission and venture overview.", "resume-service", "Founder Pitch", "Record a 90-second startup introduction.", "interview-service"),
day("Validate Your Idea", "Product positioning clarified through customer validation.", "Mission Matters", "Publish your vision and value proposition.", "social-branding-service", "Market Radar", "Review customer opportunities and industry trends.", "matchmaking-service", "ProblemSolution Canvas", "Build your customer problem statement and solution.", "resume-service", "Customer Discovery", "Practice customer discovery conversations.", "roleplay-service"),
day("Build Credibility", "Investor-ready business story developed.", "Show Your Progress", "Share an update about your product or startup.", "social-branding-service", "Traction Pulse", "Track business readiness and founder score.", "qscore-service", "Pitch Deck Builder", "Create an investor-ready pitch deck.", "resume-service", "Investor Warm-Up", "Practice investor introduction and elevator pitch.", "interview-service"),
day("Find Your Market", "Business proof strengthened with qualified opportunities identified.", "Become Discoverable", "Optimize founder profile and startup page.", "social-branding-service", "Opportunity Scan", "Explore customers, investors, grants and partnerships.", "matchmaking-service", "Business Portfolio", "Upload MVP screenshots, website or product demo.", "resume-service", "Sales Conversation", "Roleplay first customer sales meeting.", "roleplay-service"),
day("Grow Smarter", "Founder capability expanded through learning and negotiation practice.", "Lead With Value", "Publish an industry insight or founder journey.", "social-branding-service", "Growth Snapshot", "Review Dashboard, customer pipeline and QX progress.", "qscore-service", "Learning Sprint", "Complete one founder-focused course.", "courses-service", "Negotiation Lab", "Practice pricing, partnership or investment negotiation.", "roleplay-service"),
day("Build Your Network", "Strategic relationships established with founders, mentors and investors.", "Community Builder", "Engage with founders and startup communities.", "social-branding-service", "Network Intelligence", "Review strategic matches and collaboration opportunities.", "matchmaking-service", "Expert Connect", "Book a mentor session and refine your strategy.", "expert-service", "Founder Roundtable", "Attend a GrowQR startup event or networking session.", "events-service"),
day("Launch Forward", "Founder completes a 7-day launch roadmap with measurable business readiness and a clear action plan.", "Founder Spotlight", "Publish your startup milestone and growth roadmap.", "social-branding-service", "Growth Scorecard", "Review Dashboard, Founder QX Score and capability improvements.", "qscore-service", "Startup Blueprint", "Generate your AI-powered Growth & Funding Blueprint.", "resume-service", "Vision Presentation", "Deliver your final investor/customer presentation.", "interview-service"),
]),
enterprise: staticIcpTemplate("enterprise", "Enterprise", "7-Day Enterprise Capability Sprint", "Activate workforce capability proof, structured hiring practice, leadership simulations, and learning pathways.", [
day("Know Your Workforce", "Enterprise capability baseline established with organizational priorities identified.", "Shape Your Story", "Publish or refresh your Employer Value Proposition and company profile.", "social-branding-service", "Know Your Workforce", "Review Enterprise QX Score, Dashboard and workforce capability baseline.", "qscore-service", "Capability Blueprint", "Upload competency framework, hiring roles and workforce structure.", "resume-service", "Leadership Kickoff", "Complete an executive leadership simulation.", "roleplay-service"),
day("Measure Your Talent", "Skill gaps identified and hiring standards aligned.", "Culture Showcase", "Highlight employee culture, achievements and workplace initiatives with LinkedIn Post.", "social-branding-service", "Skill Gap Radar", "Run organization-wide capability assessment.", "assessment-service", "Talent Profiles", "Create AI-ready hiring profiles and role descriptions.", "resume-service", "Interview Calibration", "Standardize interview questions with AI Interview Coach.", "roleplay-service"),
day("Strengthen Capability", "Learning pathways activated and leadership coaching initiated.", "Lead With Purpose", "Publish a leadership or employer branding update.", "social-branding-service", "Capability Heatmap", "Review department-wise readiness dashboard.", "qscore-service", "Learning Pathways", "Assign personalized learning journeys to employees.", "courses-service", "Manager Coaching", "Practice performance review conversations.", "roleplay-service"),
day("Build Better Hiring", "Hiring quality improved with standardized recruitment practices.", "Employer Spotlight", "Showcase hiring philosophy and employee success stories.", "social-branding-service", "Talent Match Scan", "Review AI talent recommendations and internal mobility insights.", "matchmaking-service", "Hiring Playbook", "Generate structured hiring toolkit and competency matrix.", "resume-service", "Hiring Lab", "Conduct structured interview simulations.", "roleplay-service"),
day("Grow Leaders", "Leadership pipeline strengthened with succession planning.", "People First", "Share a postemployee development initiatives.", "social-branding-service", "Leadership Pulse", "Review leadership readiness dashboard and QX trends.", "qscore-service", "Leadership Portfolio", "Document succession plans and leadership competencies.", "resume-service", "Executive Roleplay", "Simulate stakeholder, boardroom and conflict scenarios.", "roleplay-service"),
day("Activate Growth", "Organizational capability accelerated through expert intervention.", "Innovation Culture", "Promote innovation, awards and organizational achievements on social media.", "social-branding-service", "Growth Snapshot", "Track workforce learning completion and hiring analytics.", "qscore-service", "Expert Connect", "Book an expert consultation or capability workshop.", "expert-service", "Capability Workshop", "Attend GrowQR expert-led enterprise learning session.", "events-service"),
day("Future Ready Enterprise", "Enterprise receives a complete workforce capability roadmap with measurable actions for hiring, learning, leadership and transformation.", "Future Forward", "Launch employer branding campaign across digital channels.", "social-branding-service", "Capability Scorecard", "Review Enterprise Dashboard, QX Score improvements and analytics.", "qscore-service", "Transformation Blueprint", "Generate AI-powered workforce transformation report.", "resume-service", "Leadership Summit", "Complete executive simulation and strategic planning workshop.", "events-service"),
]),
};
export function seed(
taskType: CuratorTaskType,
serviceId: CuratorServiceId,
title: string,
subtitle: string,
effort: string,
qxImpact: string,
cta: string,
signals: string[],
): TaskSeed {
return { taskType, serviceId, title, subtitle, effort, qxImpact, cta: unavailableCta(serviceId, cta), signals };
}
type StaticSprintDay = {
focus: string;
outcome: string;
socialTitle: string;
socialTask: string;
socialServiceId: CuratorServiceId;
measurementTitle: string;
measurementTask: string;
measurementServiceId: CuratorServiceId;
proofTitle: string;
proofTask: string;
proofServiceId: CuratorServiceId;
practiceTitle: string;
practiceTask: string;
practiceServiceId: CuratorServiceId;
};
function day(
focus: string,
outcome: string,
socialTitle: string,
socialTask: string,
socialServiceId: CuratorServiceId,
measurementTitle: string,
measurementTask: string,
measurementServiceId: CuratorServiceId,
proofTitle: string,
proofTask: string,
proofServiceId: CuratorServiceId,
practiceTitle: string,
practiceTask: string,
practiceServiceId: CuratorServiceId,
): StaticSprintDay {
return {
focus,
outcome,
socialTitle,
socialTask,
socialServiceId,
measurementTitle,
measurementTask,
measurementServiceId,
proofTitle,
proofTask,
proofServiceId,
practiceTitle,
practiceTask,
practiceServiceId,
};
}
function staticWeekTemplate(days: StaticSprintDay[]): WeekTemplate {
return {
theme: "Static 7-Day CareerSprint",
summary: "Static day-wise sprint tasks sourced from the GrowQR task library and 7-day sprint document.",
days: days.map((item) => {
return {
focus: item.focus,
outcome: item.outcome,
measurement: seed(
"proof",
item.socialServiceId,
item.socialTitle,
item.socialTask,
"10 min",
"+6 projected",
"Open social proof",
["static social proof", item.socialTitle.toLowerCase()],
),
proof: seed(
"measurement",
item.measurementServiceId,
item.measurementTitle,
item.measurementTask,
"10 min",
"+6 projected",
"Open measurement",
["static measurement", item.measurementTitle.toLowerCase()],
),
practice: seed(
"proof",
item.proofServiceId,
item.proofTitle,
item.proofTask,
"10 min",
"+7 projected",
"Open proof task",
["static proof", item.proofTitle.toLowerCase()],
),
roleplay: seed(
"practice",
item.practiceServiceId,
item.practiceTitle,
item.practiceTask,
"10 min",
"+10 projected",
"Open practice task",
["static practice", item.practiceTitle.toLowerCase()],
),
};
}),
};
}
function staticIcpTemplate(
id: CuratorIcpId,
label: string,
sprintTheme: string,
goal: string,
days: StaticSprintDay[],
): CuratorIcpTemplateSet {
return {
id,
label,
sprintTheme,
goal,
weeks: [staticWeekTemplate(days)],
};
}
export function normalizeActiveCuratorTaskSeed(task: TaskSeed): TaskSeed {
return task;
}
export function templateSetFor(variantId: CuratorIcpId): CuratorIcpTemplateSet {
return CURATOR_TASK_REGISTRY[variantId] ?? CURATOR_TASK_REGISTRY.fresher_early_professional;
}