Compare commits
4 Commits
static-icp
...
staging
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b4f6f7d94 | ||
| 790db95bda | |||
|
|
7142c8f7e8 | ||
|
|
9062ce9f22 |
33
drizzle/0012_system_notifications.sql
Normal file
33
drizzle/0012_system_notifications.sql
Normal 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");
|
||||
@@ -85,6 +85,13 @@
|
||||
"when": 1780481600000,
|
||||
"tag": "0011_conversation_metadata",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1780481700000,
|
||||
"tag": "0012_system_notifications",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
224
src/notifications/system-notifications.ts
Normal file
224
src/notifications/system-notifications.ts
Normal 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);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -851,6 +851,11 @@ export function listServices() {
|
||||
return serviceRegistry.filter((service) => allowedServiceIds.has(service.id));
|
||||
}
|
||||
|
||||
function getServiceRecord(serviceId?: string | null) {
|
||||
const normalized = normalizeServiceId(serviceId);
|
||||
return normalized ? serviceRegistry.find((service) => service.id === normalized) : undefined;
|
||||
}
|
||||
|
||||
export function getService(serviceId?: string | null) {
|
||||
const normalized = normalizeServiceId(serviceId);
|
||||
return normalized ? listServices().find((service) => service.id === normalized) : undefined;
|
||||
@@ -1021,7 +1026,7 @@ export function getServiceToolName(serviceId?: string) {
|
||||
}
|
||||
|
||||
export function getCompletionEvents(serviceId?: string) {
|
||||
return getService(serviceId)?.curator.completionEvents ?? ["curator.task.completed"];
|
||||
return getServiceRecord(serviceId)?.curator.completionEvents ?? ["curator.task.completed"];
|
||||
}
|
||||
|
||||
export const getServiceCompletionEvents = getCompletionEvents;
|
||||
|
||||
@@ -5,7 +5,7 @@ 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, buildCuratorTasks } from "./curator-store.js";
|
||||
import { buildCuratorSprint, buildCuratorSprintReport, buildCuratorTasks } from "./curator-store.js";
|
||||
|
||||
let _client: Client<Registry> | null = null;
|
||||
function getClient(): Client<Registry> {
|
||||
@@ -73,6 +73,32 @@ export function v1CuratorRoutes() {
|
||||
}));
|
||||
});
|
||||
|
||||
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(() => ({})));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { and, desc, eq, gte, inArray, sql } from "drizzle-orm";
|
||||
import { db } from "../../db/client.js";
|
||||
import { growEvents, growQscoreProjectionState } from "../../db/schema.js";
|
||||
import { growEvents, growQscoreProjectionState, missionArtifacts } from "../../db/schema.js";
|
||||
import { listMissionDefinitions } from "../../missions/registry.js";
|
||||
import { listServiceCapabilities } from "../../workflows/service-capabilities.js";
|
||||
import { emitCuratorEvent } from "./curator-events.js";
|
||||
@@ -11,7 +11,10 @@ import type {
|
||||
CuratorPlan,
|
||||
CuratorPlanDay,
|
||||
CuratorImprovementSignal,
|
||||
CuratorKpiReportItem,
|
||||
CuratorOutcomeReportItem,
|
||||
CuratorServiceId,
|
||||
CuratorSprintReport,
|
||||
CuratorSprintResponse,
|
||||
CuratorStreak,
|
||||
CuratorTask,
|
||||
@@ -20,6 +23,8 @@ import type {
|
||||
} from "./curator-types.js";
|
||||
import { buildCuratorTaskDeepLink, completionEventsForService, serviceName, serviceToolName } from "./curator-service-links.js";
|
||||
import { normalizeActiveCuratorTaskSeed, seed, templateSetFor, type CuratorIcpTemplateSet, type TaskSeed, type WeekTemplate } from "./task-registry.js";
|
||||
import { kpiDefinitionsForIcp } from "./kpi-registry.js";
|
||||
import { outcomeDefinitionsForIcp } from "./outcome-report-registry.js";
|
||||
|
||||
const VALID_COMPLETION_TYPES = [
|
||||
"service.completed",
|
||||
@@ -891,6 +896,21 @@ type CuratorSprintBuildOptions = {
|
||||
dayIndex?: number;
|
||||
};
|
||||
|
||||
type CuratorSprintReportBuildOptions = CuratorSprintBuildOptions & {
|
||||
save?: boolean;
|
||||
};
|
||||
|
||||
const CAREERSPRINT_REPORT_ARTIFACT_TYPE = "careersprint_report";
|
||||
const REPORT_DAY_COUNT = 7;
|
||||
|
||||
const TERMINAL_REPORT_STATUSES: CuratorTask["status"][] = [
|
||||
"completed",
|
||||
"blocked",
|
||||
"partial",
|
||||
"skipped",
|
||||
"abandoned",
|
||||
];
|
||||
|
||||
async function buildCuratorSprintInternal(userId: string, focusDate = todayIso(), options: CuratorSprintBuildOptions = {}): Promise<CuratorSprintResponse> {
|
||||
const demoDayIndex = options.dayIndex ? clamp(options.dayIndex, 1, SPRINT_DURATION_DAYS) : undefined;
|
||||
const demoStartDate = demoDayIndex ? addDaysIso(focusDate, -(demoDayIndex - 1)) : focusDate;
|
||||
@@ -1022,6 +1042,157 @@ export async function buildCuratorSprint(userId: string, date = todayIso(), opti
|
||||
return buildCuratorSprintInternal(userId, date, options);
|
||||
}
|
||||
|
||||
function taskIsTerminalForReport(task: CuratorTask) {
|
||||
return TERMINAL_REPORT_STATUSES.includes(task.status);
|
||||
}
|
||||
|
||||
function taskEvidenceForKpi(task: CuratorTask) {
|
||||
if (!task.serviceId || !taskIsTerminalForReport(task)) return null;
|
||||
return {
|
||||
serviceId: task.serviceId,
|
||||
taskId: task.id,
|
||||
status: task.status,
|
||||
};
|
||||
}
|
||||
|
||||
function computeReportEligibility(sprint: CuratorSprintResponse) {
|
||||
const firstSevenDays = sprint.plan.days.slice(0, REPORT_DAY_COUNT);
|
||||
if (sprint.activeDayIndex < REPORT_DAY_COUNT) {
|
||||
return { eligible: false, reason: "Report unlocks after Day 7 of the static sprint." };
|
||||
}
|
||||
if (firstSevenDays.length < REPORT_DAY_COUNT) {
|
||||
return { eligible: false, reason: "Seven static sprint days are not available yet." };
|
||||
}
|
||||
const tasks = firstSevenDays.flatMap((day) => day.tasks);
|
||||
if (tasks.length < REPORT_DAY_COUNT * 4) {
|
||||
return { eligible: false, reason: "Seven-day task evidence is still being prepared." };
|
||||
}
|
||||
if (!tasks.every(taskIsTerminalForReport)) {
|
||||
return { eligible: false, reason: "Finish or close all Day 1-7 tasks to unlock the report." };
|
||||
}
|
||||
return { eligible: true, reason: "Seven-day sprint evidence is ready." };
|
||||
}
|
||||
|
||||
function computeReportKpis(icpId: CuratorIcpId, tasks: CuratorTask[]): CuratorKpiReportItem[] {
|
||||
return kpiDefinitionsForIcp(icpId).map((definition) => {
|
||||
const evidence = tasks
|
||||
.filter((task) => {
|
||||
if (!task.serviceId || !definition.requiredServiceIds.includes(task.serviceId)) return false;
|
||||
if (definition.requiredTaskTypes?.length && !definition.requiredTaskTypes.includes(task.taskType)) return false;
|
||||
return taskIsTerminalForReport(task);
|
||||
})
|
||||
.map(taskEvidenceForKpi)
|
||||
.filter((item): item is NonNullable<ReturnType<typeof taskEvidenceForKpi>> => Boolean(item));
|
||||
|
||||
const met = evidence.some((item) => item.status === "completed");
|
||||
return {
|
||||
id: definition.id,
|
||||
category: definition.category,
|
||||
label: definition.label,
|
||||
met,
|
||||
evidence,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function computeReportOutcomes(icpId: CuratorIcpId, kpis: CuratorKpiReportItem[]): CuratorOutcomeReportItem[] {
|
||||
const metKpiIds = new Set(kpis.filter((kpi) => kpi.met).map((kpi) => kpi.id));
|
||||
return outcomeDefinitionsForIcp(icpId).map((definition) => ({
|
||||
id: definition.id,
|
||||
text: definition.text,
|
||||
requiredKpiIds: definition.requiredKpiIds,
|
||||
met: definition.requiredKpiIds.every((kpiId) => metKpiIds.has(kpiId)),
|
||||
}));
|
||||
}
|
||||
|
||||
function reportFromArtifact(metadata: Record<string, unknown> | null | undefined): CuratorSprintReport | null {
|
||||
const report = metadata?.report;
|
||||
if (!report || typeof report !== "object") return null;
|
||||
return { ...(report as CuratorSprintReport), saved: true };
|
||||
}
|
||||
|
||||
async function loadSavedSprintReport(userId: string, sprintId: string): Promise<CuratorSprintReport | null> {
|
||||
const [artifact] = await db
|
||||
.select({ metadata: missionArtifacts.metadata })
|
||||
.from(missionArtifacts)
|
||||
.where(and(
|
||||
eq(missionArtifacts.userId, userId),
|
||||
eq(missionArtifacts.missionInstanceId, sprintId),
|
||||
eq(missionArtifacts.type, CAREERSPRINT_REPORT_ARTIFACT_TYPE),
|
||||
))
|
||||
.orderBy(desc(missionArtifacts.updatedAt))
|
||||
.limit(1);
|
||||
return reportFromArtifact(artifact?.metadata);
|
||||
}
|
||||
|
||||
async function saveSprintReport(report: CuratorSprintReport) {
|
||||
await db.insert(missionArtifacts).values({
|
||||
userId: report.userId,
|
||||
missionInstanceId: report.sprintId,
|
||||
missionId: "curator-sprint",
|
||||
stageId: "careersprint-report",
|
||||
serviceId: "qscore-service",
|
||||
externalId: `${report.sprintId}:report`,
|
||||
type: CAREERSPRINT_REPORT_ARTIFACT_TYPE,
|
||||
title: `${report.icpLabel} CareerSprint report`,
|
||||
status: report.eligible ? "ready" : "blocked",
|
||||
summary: report.eligible ? "Seven-day CareerSprint report generated." : report.eligibilityReason,
|
||||
metadata: { report: { ...report, saved: true } },
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildCuratorSprintReport(
|
||||
userId: string,
|
||||
date = todayIso(),
|
||||
options: CuratorSprintReportBuildOptions = {},
|
||||
): Promise<CuratorSprintReport> {
|
||||
const demoDayIndex = options.dayIndex ? clamp(options.dayIndex, 1, SPRINT_DURATION_DAYS) : undefined;
|
||||
const demoStartDate = demoDayIndex ? addDaysIso(date, -(demoDayIndex - 1)) : date;
|
||||
const sprintState = options.icpId
|
||||
? {
|
||||
startDate: demoStartDate,
|
||||
variantId: options.icpId,
|
||||
planDays: planSeedsForVariant(templateSetFor(options.icpId), demoStartDate),
|
||||
}
|
||||
: await loadSprintState(userId, date);
|
||||
const sprint = await buildCuratorSprintInternal(userId, date, options);
|
||||
const savedReport = options.save || options.icpId ? null : await loadSavedSprintReport(userId, sprint.sprintId);
|
||||
if (savedReport && savedReport.icpId === sprintState.variantId) return savedReport;
|
||||
|
||||
const templateSet = templateSetFor(sprintState.variantId);
|
||||
const firstSevenDays = sprint.plan.days.slice(0, REPORT_DAY_COUNT);
|
||||
const reportTasks = firstSevenDays.flatMap((day) => day.tasks);
|
||||
const eligibility = computeReportEligibility(sprint);
|
||||
const kpis = computeReportKpis(sprintState.variantId, reportTasks);
|
||||
const outcomes = computeReportOutcomes(sprintState.variantId, kpis);
|
||||
const report: CuratorSprintReport = {
|
||||
userId,
|
||||
icpId: sprintState.variantId,
|
||||
icpLabel: templateSet.label,
|
||||
sprintId: sprint.sprintId,
|
||||
generatedAt: new Date().toISOString(),
|
||||
saved: false,
|
||||
eligible: eligibility.eligible,
|
||||
eligibilityReason: eligibility.reason,
|
||||
days: firstSevenDays.map((day) => ({
|
||||
dayIndex: day.dayIndex,
|
||||
title: day.focus ?? `Day ${day.dayIndex}`,
|
||||
outcome: day.outcome ?? "Daily outcome captured.",
|
||||
completedCount: day.completedCount,
|
||||
totalCount: day.totalCount,
|
||||
})),
|
||||
kpis,
|
||||
outcomes,
|
||||
};
|
||||
|
||||
if (options.save) {
|
||||
await saveSprintReport(report);
|
||||
return { ...report, saved: true };
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
export async function buildServiceCurationPreview(input: ServiceCurationPreviewInput) {
|
||||
const startDate = input.startDate ?? todayIso();
|
||||
const inferredIcpId = input.icpId ?? await inferIcpVariant(input.userId);
|
||||
|
||||
@@ -145,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>;
|
||||
@@ -152,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;
|
||||
|
||||
150
src/v1/curator/kpi-registry.ts
Normal file
150
src/v1/curator/kpi-registry.ts
Normal 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;
|
||||
}
|
||||
101
src/v1/curator/outcome-report-registry.ts
Normal file
101
src/v1/curator/outcome-report-registry.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user