fix curator sprint ICP reconciliation
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
"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",
|
||||
"test:curator-reconcile": "tsx scripts/curator-persisted-reconcile.test.ts",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"workflows:smoke": "tsx src/workflows/smoke-test.ts",
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
curatorSprintPlanFingerprint,
|
||||
curatorSprintTaskPlan,
|
||||
decideCuratorSprintReconciliation,
|
||||
resolveCuratorSprintAccess,
|
||||
shouldReconcileCuratorSprint,
|
||||
} from "../src/v1/curator/curator-store.js";
|
||||
import { templateSetFor, trialDaysFor } from "../src/v1/curator/task-registry.js";
|
||||
|
||||
const usersRoute = readFileSync(new URL("../src/routes/users.ts", import.meta.url), "utf8");
|
||||
const ledgerSource = readFileSync(new URL("../src/events/onboarding-ledger.ts", import.meta.url), "utf8");
|
||||
@@ -44,4 +47,31 @@ const unauthorizedFull = resolveCuratorSprintAccess({ plan: "free", accessChoice
|
||||
assert.deepEqual(unauthorizedFull, { access: "trial", durationDays: 2 });
|
||||
assert.equal(shouldReconcileCuratorSprint(existingTrial, unauthorizedFull), false);
|
||||
|
||||
const startDate = "2026-07-13";
|
||||
const studentFingerprint = curatorSprintPlanFingerprint({ startDate, variantId: "student_recent_grad", durationDays: 2, access: "trial" });
|
||||
const experiencedFingerprint = curatorSprintPlanFingerprint({ startDate, variantId: "experienced_professional", durationDays: 2, access: "trial" });
|
||||
const experiencedPlan = curatorSprintTaskPlan({ startDate, variantId: "experienced_professional", durationDays: 2, access: "trial" });
|
||||
assert.notEqual(studentFingerprint, experiencedFingerprint, "ICP changes must change the compatibility fingerprint");
|
||||
assert.equal(shouldReconcileCuratorSprint(
|
||||
{ access: "trial", durationDays: 2, variantId: "student_recent_grad", planFingerprint: studentFingerprint },
|
||||
{ access: "trial", durationDays: 2, variantId: "experienced_professional", planFingerprint: experiencedFingerprint },
|
||||
), true, "student started/ready lineage must reconcile to experienced trial");
|
||||
const experiencedTrial = trialDaysFor("experienced_professional");
|
||||
assert.deepEqual(experiencedPlan[0]?.tasks.map((task) => task.title), [
|
||||
experiencedTrial[0]!.socialTitle,
|
||||
experiencedTrial[0]!.measurementTitle,
|
||||
experiencedTrial[0]!.proofTitle,
|
||||
experiencedTrial[0]!.practiceTitle,
|
||||
]);
|
||||
assert.deepEqual(experiencedPlan[1]?.tasks.map((task) => task.title), [
|
||||
experiencedTrial[1]!.socialTitle,
|
||||
experiencedTrial[1]!.measurementTitle,
|
||||
experiencedTrial[1]!.proofTitle,
|
||||
experiencedTrial[1]!.practiceTitle,
|
||||
]);
|
||||
assert.equal(experiencedPlan[0]?.tasks.length, 4);
|
||||
assert.equal(experiencedPlan[1]?.tasks.length, 4);
|
||||
assert.deepEqual(experiencedPlan[0]?.tasks.map((task) => task.id), experiencedPlan[0]?.tasks.map((task) => task.id));
|
||||
assert.equal(templateSetFor("experienced_professional").id, "experienced_professional");
|
||||
|
||||
console.log("curator onboarding reconciliation tests passed");
|
||||
|
||||
148
scripts/curator-persisted-reconcile.test.ts
Normal file
148
scripts/curator-persisted-reconcile.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { db } from "../src/db/client.js";
|
||||
import { growEvents, onboarding, users } from "../src/db/schema.js";
|
||||
import { buildCuratorSprint, curatorSprintTaskPlan } from "../src/v1/curator/curator-store.js";
|
||||
import { runCuratorOnboardingLoop } from "../src/v1/curator/curator-onboarding-loop.js";
|
||||
import { CURATOR_TRIAL_TASK_REGISTRY } from "../src/v1/curator/task-registry.js";
|
||||
|
||||
let databaseAvailable = Boolean(process.env.DATABASE_URL);
|
||||
if (databaseAvailable) {
|
||||
try {
|
||||
await db.execute(sql`select 1`);
|
||||
} catch {
|
||||
databaseAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!databaseAvailable) {
|
||||
console.log("curator persisted reconciliation integration skipped: DATABASE_URL is not reachable");
|
||||
} else {
|
||||
const userId = `curator-reconcile-regression-${Date.now()}`;
|
||||
const startDate = new Date().toISOString().slice(0, 10);
|
||||
const sprintId = `curator-sprint:icp-v10-static:${startDate}`;
|
||||
const staleFingerprint = JSON.stringify({ variantId: "student_recent_grad", taskIds: [] });
|
||||
const staleReadyPayload = {
|
||||
sprintId,
|
||||
startDate,
|
||||
durationDays: 2,
|
||||
access: "trial",
|
||||
variantId: "student_recent_grad",
|
||||
planFingerprint: staleFingerprint,
|
||||
};
|
||||
|
||||
try {
|
||||
await db.insert(users).values({ id: userId, email: `${userId}@example.test`, plan: "pro" });
|
||||
await db.insert(onboarding).values({
|
||||
userId,
|
||||
data: {
|
||||
status: "completed",
|
||||
access_choice: "trial",
|
||||
profile: { icp: "experienced" },
|
||||
},
|
||||
payload: {
|
||||
onboarding_icp: "experienced",
|
||||
curator_registry_icp: "experienced_professional",
|
||||
},
|
||||
});
|
||||
await db.insert(growEvents).values([
|
||||
{
|
||||
userId,
|
||||
source: "curator-v1",
|
||||
type: "curator.sprint.started",
|
||||
category: "mission",
|
||||
occurredAt: new Date(),
|
||||
dedupeKey: `${userId}:curator.sprint.started:stale`,
|
||||
payload: {
|
||||
sprintId,
|
||||
startDate,
|
||||
durationDays: 2,
|
||||
access: "trial",
|
||||
version: "icp-v10-static",
|
||||
variantId: "student_recent_grad",
|
||||
},
|
||||
},
|
||||
{
|
||||
userId,
|
||||
source: "curator-v1",
|
||||
type: "curator.onboarding_plan.ready",
|
||||
category: "mission",
|
||||
occurredAt: new Date(),
|
||||
dedupeKey: `${userId}:curator.onboarding_plan.ready:stale`,
|
||||
payload: staleReadyPayload,
|
||||
},
|
||||
]);
|
||||
|
||||
const context = {
|
||||
onboarding: { status: "completed", access_choice: "trial", profile: { icp: "experienced" } },
|
||||
preferences: { onboarding: { status: "completed", access_choice: "trial", profile: { icp: "experienced" } } },
|
||||
};
|
||||
const first = await runCuratorOnboardingLoop({ userId, completedAt: `${startDate}T09:00:00.000Z`, context });
|
||||
assert.equal(first.status, "ready", "stale ready event must be replaced");
|
||||
|
||||
const day1 = await buildCuratorSprint(userId, startDate);
|
||||
assert.deepEqual(day1.plan.days[0]?.tasks.map(({ id, title }) => ({ id, title })),
|
||||
expectedTasks(startDate, 1), "Day 1 must expose experienced trial tasks");
|
||||
assert.deepEqual(day1.plan.days[1]?.tasks, [], "Day 1 must hide future Day 2 tasks");
|
||||
|
||||
const noCompletionDay2 = await buildCuratorSprint(userId, addDays(startDate, 1));
|
||||
const noCompletionTitles = noCompletionDay2.plan.days[1]?.tasks.map((task) => task.title) ?? [];
|
||||
assert.ok(noCompletionTitles.includes("Executive Leadership Interview"), "zero-completion Day 2 keeps experienced recovery behavior");
|
||||
assert.ok(!noCompletionTitles.some((title) => ["Complete GrowQR Profile", "Career Baseline", "First AI Resume", "Self-Introduction"].includes(title)), "recovery must not reintroduce student tasks");
|
||||
|
||||
await db.insert(growEvents).values((day1.plan.days[0]?.tasks ?? []).map((task, index) => ({
|
||||
userId,
|
||||
source: "curator-v1",
|
||||
type: "curator.task.completed",
|
||||
category: "mission" as const,
|
||||
occurredAt: new Date(Date.now() + index),
|
||||
dedupeKey: `${userId}:curator.task.completed:${task.id}`,
|
||||
payload: { taskId: task.id },
|
||||
})));
|
||||
|
||||
const day2 = await buildCuratorSprint(userId, addDays(startDate, 1));
|
||||
assert.deepEqual(day2.plan.days[1]?.tasks.map(({ id, title }) => ({ id, title })),
|
||||
expectedTasks(startDate, 2), "completed Day 1 must expose exact experienced Day 2 registry tasks");
|
||||
|
||||
const second = await runCuratorOnboardingLoop({ userId, completedAt: `${startDate}T09:00:00.000Z`, context });
|
||||
assert.equal(second.status, "already_ready", "compatible replacement ready event must be reused");
|
||||
|
||||
const rows = await db.select({ type: growEvents.type, payload: growEvents.payload })
|
||||
.from(growEvents).where(eq(growEvents.userId, userId));
|
||||
assert.equal(rows.filter((row) => row.type === "curator.sprint.invalidated").length, 1);
|
||||
assert.equal(rows.filter((row) => row.type === "curator.onboarding_plan.invalidated").length, 1);
|
||||
const starts = rows.filter((row) => row.type === "curator.sprint.started");
|
||||
const readies = rows.filter((row) => row.type === "curator.onboarding_plan.ready");
|
||||
assert.equal(starts.length, 2, "one stale and one replacement started lineage");
|
||||
assert.equal(readies.length, 2, "one stale and one replacement ready lineage");
|
||||
const replacementStart = starts.find((row) => row.payload?.variantId === "experienced_professional");
|
||||
const replacementReady = readies.find((row) => row.payload?.planFingerprint === day1.plan.planFingerprint);
|
||||
assert.ok(replacementStart, "experienced replacement started event must be persisted");
|
||||
assert.ok(replacementReady, "experienced replacement ready event must be persisted");
|
||||
|
||||
console.log("curator persisted reconciliation integration passed");
|
||||
} finally {
|
||||
await db.delete(users).where(eq(users.id, userId));
|
||||
await db.execute(sql`select 1`);
|
||||
}
|
||||
}
|
||||
|
||||
function addDays(date: string, days: number) {
|
||||
const value = new Date(`${date}T00:00:00.000Z`);
|
||||
value.setUTCDate(value.getUTCDate() + days);
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function expectedTasks(startDate: string, dayIndex: 1 | 2) {
|
||||
const plan = curatorSprintTaskPlan({
|
||||
startDate,
|
||||
variantId: "experienced_professional",
|
||||
durationDays: 2,
|
||||
access: "trial",
|
||||
});
|
||||
const registry = CURATOR_TRIAL_TASK_REGISTRY.experienced_professional[dayIndex - 1]!;
|
||||
return plan[dayIndex - 1]!.tasks.map(({ id }, index) => ({
|
||||
id,
|
||||
title: [registry.socialTitle, registry.measurementTitle, registry.proofTitle, registry.practiceTitle][index]!,
|
||||
}));
|
||||
}
|
||||
@@ -7,13 +7,16 @@ function curatorDedupeKey(input: {
|
||||
payload?: Record<string, unknown>;
|
||||
}) {
|
||||
const payload = input.payload ?? {};
|
||||
const stableId =
|
||||
payload.taskId ??
|
||||
payload.sprintId ??
|
||||
payload.startDate ??
|
||||
payload.sourceEventId ??
|
||||
payload.eventId ??
|
||||
payload.date;
|
||||
const stableId = input.type.includes("invalidated")
|
||||
? `${payload.staleSprintEventId ?? payload.staleReadyEventId ?? payload.staleEventId ?? "unknown"}:${payload.planFingerprint ?? "legacy"}`
|
||||
: payload.planFingerprint ??
|
||||
payload.taskPlanFingerprint ??
|
||||
payload.taskId ??
|
||||
payload.sprintId ??
|
||||
payload.startDate ??
|
||||
payload.sourceEventId ??
|
||||
payload.eventId ??
|
||||
payload.date;
|
||||
|
||||
return `${input.userId}:${input.type}:${stableId ?? Date.now()}`;
|
||||
}
|
||||
|
||||
@@ -276,23 +276,48 @@ export async function runCuratorOnboardingLoop(input: OnboardingLoopInput): Prom
|
||||
|
||||
const existing = await findExistingReadyEvent(userId);
|
||||
if (existing) {
|
||||
const existingDuration = existing.payload?.durationDays === 7 ? 7 : existing.payload?.durationDays === 2 ? 2 : undefined;
|
||||
if (existingDuration !== sprint.plan.durationDays) {
|
||||
const existingFingerprint = typeof existing.payload?.planFingerprint === "string" ? existing.payload.planFingerprint : undefined;
|
||||
const existingIcpId = typeof existing.payload?.icpId === "string"
|
||||
? existing.payload.icpId
|
||||
: typeof existing.payload?.variantId === "string" ? existing.payload.variantId : undefined;
|
||||
const compatible = existingFingerprint === sprint.plan.planFingerprint && existingIcpId === sprint.plan.icpId;
|
||||
if (compatible) return { status: "already_ready", readyEventId: existing.id, sprint };
|
||||
|
||||
const invalidations = await db
|
||||
.select({ payload: growEvents.payload })
|
||||
.from(growEvents)
|
||||
.where(and(
|
||||
eq(growEvents.userId, userId),
|
||||
eq(growEvents.source, CURATOR_SOURCE),
|
||||
eq(growEvents.type, "curator.onboarding_plan.invalidated"),
|
||||
))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(20);
|
||||
const alreadyInvalidated = invalidations.some((row) => (
|
||||
row.payload?.staleReadyEventId === existing.id
|
||||
&& row.payload?.planFingerprint === sprint.plan.planFingerprint
|
||||
));
|
||||
if (!alreadyInvalidated) {
|
||||
await emitCuratorEvent({
|
||||
userId,
|
||||
type: "curator.onboarding_plan.invalidated",
|
||||
payload: {
|
||||
sprintId: sprint.sprintId,
|
||||
staleReadyEventId: existing.id,
|
||||
previousDurationDays: existingDuration,
|
||||
access: sprint.plan.durationDays === 7 ? "full" : "trial",
|
||||
previousIcpId: existingIcpId,
|
||||
previousDurationDays: existing.payload?.durationDays,
|
||||
previousPlanFingerprint: existingFingerprint,
|
||||
icpId: sprint.plan.icpId,
|
||||
variantId: sprint.plan.icpId,
|
||||
access: sprint.plan.access,
|
||||
durationDays: sprint.plan.durationDays,
|
||||
planFingerprint: sprint.plan.planFingerprint,
|
||||
taskPlan: sprint.plan.taskPlan,
|
||||
dayOneTasks: dayOneTasks(sprint),
|
||||
reason: "onboarding_access_choice_changed",
|
||||
reason: "onboarding_plan_changed",
|
||||
},
|
||||
});
|
||||
}
|
||||
return { status: "already_ready", readyEventId: existing.id, sprint };
|
||||
}
|
||||
|
||||
const event = await emitCuratorEvent({
|
||||
@@ -307,11 +332,16 @@ export async function runCuratorOnboardingLoop(input: OnboardingLoopInput): Prom
|
||||
sprintId: sprint.sprintId,
|
||||
planId: sprint.plan.id,
|
||||
durationDays: sprint.plan.durationDays,
|
||||
access: sprint.plan.durationDays === 7 ? "full" : "trial",
|
||||
access: sprint.plan.access,
|
||||
icpId: sprint.plan.icpId,
|
||||
variantId: sprint.plan.icpId,
|
||||
planFingerprint: sprint.plan.planFingerprint,
|
||||
taskPlan: sprint.plan.taskPlan,
|
||||
weekCount: sprint.plan.weeks.length,
|
||||
dayCount: sprint.plan.days.length,
|
||||
activeDayIndex: sprint.activeDayIndex,
|
||||
weeklyThemes: weeklyThemes(sprint),
|
||||
// Keep the future-day visibility contract: this is Day 1 only.
|
||||
dayOneTasks: dayOneTasks(sprint),
|
||||
notificationId: `curator:onboarding-plan-ready:${userId}`,
|
||||
},
|
||||
|
||||
@@ -75,6 +75,11 @@ export type CuratorSprintAccess = {
|
||||
durationDays: 2 | 7;
|
||||
};
|
||||
|
||||
export type CuratorSprintCompatibility = CuratorSprintAccess & {
|
||||
variantId?: CuratorIcpId;
|
||||
planFingerprint?: string;
|
||||
};
|
||||
|
||||
type CuratorSprintEntitlementInput = {
|
||||
plan?: unknown;
|
||||
accessChoice?: unknown;
|
||||
@@ -96,23 +101,31 @@ export function resolveCuratorSprintAccess(input: CuratorSprintEntitlementInput)
|
||||
}
|
||||
|
||||
export function shouldReconcileCuratorSprint(
|
||||
existing: Pick<CuratorSprintAccess, "access" | "durationDays"> | undefined,
|
||||
desired: CuratorSprintAccess,
|
||||
existing: CuratorSprintCompatibility | undefined,
|
||||
desired: CuratorSprintCompatibility,
|
||||
) {
|
||||
return !existing || existing.access !== desired.access || existing.durationDays !== desired.durationDays;
|
||||
if (!existing || existing.access !== desired.access || existing.durationDays !== desired.durationDays) return true;
|
||||
if (desired.variantId !== undefined && existing.variantId !== desired.variantId) return true;
|
||||
if (desired.planFingerprint !== undefined && existing.planFingerprint !== desired.planFingerprint) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export type CuratorSprintReconciliation =
|
||||
| { action: "reuse"; access: "trial" | "full"; durationDays: 2 | 7 }
|
||||
| { action: "invalidateAndRebuild"; access: "trial" | "full"; durationDays: 2 | 7 };
|
||||
| ({ action: "reuse" } & CuratorSprintAccess & Partial<Pick<CuratorSprintCompatibility, "variantId" | "planFingerprint">>)
|
||||
| ({ action: "invalidateAndRebuild" } & CuratorSprintAccess & Partial<Pick<CuratorSprintCompatibility, "variantId" | "planFingerprint">>);
|
||||
|
||||
export function decideCuratorSprintReconciliation(
|
||||
existing: Pick<CuratorSprintAccess, "access" | "durationDays"> | undefined,
|
||||
desired: CuratorSprintAccess,
|
||||
existing: CuratorSprintCompatibility | undefined,
|
||||
desired: CuratorSprintCompatibility,
|
||||
): CuratorSprintReconciliation {
|
||||
return shouldReconcileCuratorSprint(existing, desired)
|
||||
? { action: "invalidateAndRebuild", access: desired.access, durationDays: desired.durationDays }
|
||||
: { action: "reuse", access: desired.access, durationDays: desired.durationDays };
|
||||
const action = shouldReconcileCuratorSprint(existing, desired) ? "invalidateAndRebuild" : "reuse";
|
||||
return {
|
||||
action,
|
||||
access: desired.access,
|
||||
durationDays: desired.durationDays,
|
||||
...(desired.variantId === undefined ? {} : { variantId: desired.variantId }),
|
||||
...(desired.planFingerprint === undefined ? {} : { planFingerprint: desired.planFingerprint }),
|
||||
};
|
||||
}
|
||||
|
||||
function accessChoiceFromContext(context: Record<string, unknown> | undefined): "trial" | "full" | undefined {
|
||||
@@ -473,6 +486,47 @@ export function planSeedsForVariant(
|
||||
return planDays;
|
||||
}
|
||||
|
||||
export type CuratorSprintTaskPlan = {
|
||||
dayIndex: number;
|
||||
tasks: Array<{ id: string; title: string }>;
|
||||
};
|
||||
|
||||
function taskPlanForSeeds(startDate: string, planDays: PlanDaySeed[]): CuratorSprintTaskPlan[] {
|
||||
return planDays.map((day) => ({
|
||||
dayIndex: day.dayIndex,
|
||||
tasks: day.plannedTasks.map((task) => ({
|
||||
id: taskIdFor(startDate, day.dayIndex, task.taskType, task.serviceId),
|
||||
title: task.title,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export function curatorSprintTaskPlan(input: {
|
||||
startDate: string;
|
||||
variantId: CuratorIcpId;
|
||||
durationDays: 2 | 7;
|
||||
access: "trial" | "full";
|
||||
}): CuratorSprintTaskPlan[] {
|
||||
return taskPlanForSeeds(
|
||||
input.startDate,
|
||||
planSeedsForVariant(templateSetFor(input.variantId), input.startDate, input.durationDays, input.access),
|
||||
);
|
||||
}
|
||||
|
||||
/** Stable compatibility key for the complete static sprint task plan. */
|
||||
export function curatorSprintPlanFingerprint(input: {
|
||||
startDate: string;
|
||||
variantId: CuratorIcpId;
|
||||
durationDays: 2 | 7;
|
||||
access: "trial" | "full";
|
||||
}): string {
|
||||
const taskPlan = curatorSprintTaskPlan(input);
|
||||
return JSON.stringify({
|
||||
variantId: input.variantId,
|
||||
taskIds: taskPlan.flatMap((day) => day.tasks.map((task) => task.id)),
|
||||
});
|
||||
}
|
||||
|
||||
function performanceLabel(percent: number): CuratorWeek["performance"] {
|
||||
if (percent >= 75) return "Excelling";
|
||||
if (percent >= 50) return "Avg";
|
||||
@@ -533,10 +587,61 @@ function eventText(value: unknown): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
async function inferIcpVariant(userId: string): Promise<CuratorIcpId> {
|
||||
const CURATOR_ICP_BY_ONBOARDING_ICP: Record<string, CuratorIcpId> = {
|
||||
student: "student_recent_grad",
|
||||
intern: "intern",
|
||||
fresher: "fresher_early_professional",
|
||||
experienced: "experienced_professional",
|
||||
freelancer: "freelancer",
|
||||
founder: "founder",
|
||||
};
|
||||
|
||||
function curatorIcpFromContext(context: Record<string, unknown> | undefined): CuratorIcpId | undefined {
|
||||
if (!context) return undefined;
|
||||
const preferences = context.preferences && typeof context.preferences === "object" && !Array.isArray(context.preferences)
|
||||
? context.preferences as Record<string, unknown>
|
||||
: undefined;
|
||||
const onboardingContext = context.onboarding && typeof context.onboarding === "object" && !Array.isArray(context.onboarding)
|
||||
? context.onboarding as Record<string, unknown>
|
||||
: undefined;
|
||||
const preferenceOnboarding = preferences?.onboarding && typeof preferences.onboarding === "object" && !Array.isArray(preferences.onboarding)
|
||||
? preferences.onboarding as Record<string, unknown>
|
||||
: undefined;
|
||||
const profile = onboardingContext?.profile && typeof onboardingContext.profile === "object" && !Array.isArray(onboardingContext.profile)
|
||||
? onboardingContext.profile as Record<string, unknown>
|
||||
: preferenceOnboarding?.profile && typeof preferenceOnboarding.profile === "object" && !Array.isArray(preferenceOnboarding.profile)
|
||||
? preferenceOnboarding.profile as Record<string, unknown>
|
||||
: undefined;
|
||||
const candidates = [
|
||||
context.curator_registry_icp,
|
||||
onboardingContext?.curator_registry_icp,
|
||||
preferences?.curator_registry_icp,
|
||||
context.onboarding_icp,
|
||||
onboardingContext?.onboarding_icp,
|
||||
profile?.icp,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate !== "string") continue;
|
||||
if (isCuratorIcpId(candidate)) return candidate;
|
||||
const mapped = CURATOR_ICP_BY_ONBOARDING_ICP[candidate.toLowerCase()];
|
||||
if (mapped) return mapped;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function inferIcpVariant(userId: string, context?: Record<string, unknown>, preferContext = false): Promise<CuratorIcpId> {
|
||||
const fromContext = curatorIcpFromContext(context);
|
||||
const saved = await db.query.onboarding.findFirst({ where: eq(onboarding.userId, userId) });
|
||||
const canonical = saved?.payload && typeof saved.payload === "object" ? saved.payload.curator_registry_icp : undefined;
|
||||
if (typeof canonical === "string" && isCuratorIcpId(canonical)) return canonical;
|
||||
if (preferContext && fromContext) return fromContext;
|
||||
const savedPayload = saved?.payload && typeof saved.payload === "object" ? saved.payload : undefined;
|
||||
const savedData = saved?.data && typeof saved.data === "object" ? saved.data : undefined;
|
||||
const fromSaved = curatorIcpFromContext({
|
||||
curator_registry_icp: savedPayload?.curator_registry_icp,
|
||||
onboarding_icp: savedPayload?.onboarding_icp,
|
||||
onboarding: savedData,
|
||||
});
|
||||
if (fromSaved) return fromSaved;
|
||||
if (fromContext) return fromContext;
|
||||
|
||||
const rows = await db
|
||||
.select({ type: growEvents.type, payload: growEvents.payload })
|
||||
@@ -602,14 +707,15 @@ async function loadSprintState(
|
||||
? persistedContext.onboarding as Record<string, unknown>
|
||||
: undefined;
|
||||
const contextCompleted = contextOnboarding?.status === "completed" || typeof contextOnboarding?.completed_at === "string";
|
||||
if (!dbCompleted && !contextCompleted) {
|
||||
throw new Error("onboarding_incomplete");
|
||||
}
|
||||
if (!dbCompleted && !contextCompleted) throw new Error("onboarding_incomplete");
|
||||
|
||||
const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
|
||||
const dbChoice = dbData?.access_choice;
|
||||
const accessChoice = accessChoiceFromContext(persistedContext) ?? dbChoice;
|
||||
const accessChoice = onboardingContextOverride
|
||||
? accessChoiceFromContext(persistedContext) ?? dbChoice
|
||||
: dbChoice ?? accessChoiceFromContext(persistedContext);
|
||||
const desired = resolveCuratorSprintAccess({ plan: user?.plan, accessChoice });
|
||||
const desiredVariant = await inferIcpVariant(userId, persistedContext, Boolean(onboardingContextOverride));
|
||||
|
||||
const latest = await db
|
||||
.select({ id: growEvents.id, payload: growEvents.payload })
|
||||
@@ -625,79 +731,107 @@ async function loadSprintState(
|
||||
const latestPayload = latest[0]?.payload;
|
||||
const existingStartDate = typeof latestPayload?.startDate === "string" ? latestPayload.startDate : undefined;
|
||||
const existingVersion = typeof latestPayload?.version === "string" ? latestPayload.version : undefined;
|
||||
const existingVariant = typeof latestPayload?.variantId === "string" ? latestPayload.variantId : undefined;
|
||||
const existingVariant = typeof latestPayload?.variantId === "string" && isCuratorIcpId(latestPayload.variantId)
|
||||
? latestPayload.variantId
|
||||
: undefined;
|
||||
const existingDuration = latestPayload?.durationDays === 7 ? 7 : latestPayload?.durationDays === 2 ? 2 : undefined;
|
||||
const existingAccess = latestPayload?.access === "full" ? "full" : latestPayload?.access === "trial" ? "trial" : undefined;
|
||||
const existingFingerprint = typeof latestPayload?.planFingerprint === "string" ? latestPayload.planFingerprint : undefined;
|
||||
const rebaseExistingForCompletion = Boolean(onboardingContextOverride && existingStartDate && existingStartDate !== todayDate);
|
||||
const activeExisting = Boolean(
|
||||
!rebaseExistingForCompletion && existingStartDate && existingVersion === CURATOR_PLAN_VERSION && existingVariant && isCuratorIcpId(existingVariant)
|
||||
&& existingDuration && calendarDiffDays(existingStartDate, todayDate) >= 0 && calendarDiffDays(existingStartDate, todayDate) < existingDuration,
|
||||
!rebaseExistingForCompletion && existingStartDate && existingVersion === CURATOR_PLAN_VERSION && existingVariant
|
||||
&& existingDuration && existingAccess && calendarDiffDays(existingStartDate, todayDate) >= 0 && calendarDiffDays(existingStartDate, todayDate) < existingDuration,
|
||||
);
|
||||
|
||||
let replacementStartDate = todayDate;
|
||||
if (activeExisting && existingStartDate && existingVariant && existingDuration && existingAccess) {
|
||||
const templateSet = templateSetFor(existingVariant as CuratorIcpId);
|
||||
const reconciliation = decideCuratorSprintReconciliation({ access: existingAccess, durationDays: existingDuration }, desired);
|
||||
if (reconciliation.action === "invalidateAndRebuild") {
|
||||
const invalidations = await db
|
||||
.select({ payload: growEvents.payload })
|
||||
.from(growEvents)
|
||||
.where(and(
|
||||
eq(growEvents.userId, userId),
|
||||
eq(growEvents.source, CURATOR_SOURCE),
|
||||
eq(growEvents.type, "curator.sprint.invalidated"),
|
||||
))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(20);
|
||||
const alreadyInvalidated = invalidations.some((row) => (
|
||||
row.payload?.staleSprintEventId === latest[0]?.id
|
||||
&& row.payload?.access === desired.access
|
||||
&& row.payload?.durationDays === desired.durationDays
|
||||
));
|
||||
if (!alreadyInvalidated) {
|
||||
await emitCuratorEvent({
|
||||
userId,
|
||||
type: "curator.sprint.invalidated",
|
||||
payload: {
|
||||
sprintId: sprintIdFor(existingStartDate),
|
||||
staleSprintEventId: latest[0]?.id,
|
||||
staleSprintId: sprintIdFor(existingStartDate),
|
||||
startDate: existingStartDate,
|
||||
reason: "onboarding_access_choice_changed",
|
||||
previousAccess: existingAccess,
|
||||
previousDurationDays: existingDuration,
|
||||
access: desired.access,
|
||||
durationDays: desired.durationDays,
|
||||
version: CURATOR_PLAN_VERSION,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
const desiredFingerprint = curatorSprintPlanFingerprint({
|
||||
startDate: existingStartDate,
|
||||
variantId: existingVariant as CuratorIcpId,
|
||||
variantId: desiredVariant,
|
||||
durationDays: desired.durationDays,
|
||||
access: desired.access,
|
||||
planDays: planSeedsForVariant(templateSet, existingStartDate, desired.durationDays, desired.access),
|
||||
};
|
||||
});
|
||||
const reconciliation = decideCuratorSprintReconciliation(
|
||||
{ access: existingAccess, durationDays: existingDuration, variantId: existingVariant, planFingerprint: existingFingerprint },
|
||||
{ ...desired, variantId: desiredVariant, planFingerprint: desiredFingerprint },
|
||||
);
|
||||
if (reconciliation.action === "reuse") {
|
||||
return {
|
||||
startDate: existingStartDate,
|
||||
variantId: existingVariant,
|
||||
durationDays: desired.durationDays,
|
||||
access: desired.access,
|
||||
planDays: planSeedsForVariant(templateSetFor(existingVariant), existingStartDate, desired.durationDays, desired.access),
|
||||
};
|
||||
}
|
||||
|
||||
const invalidations = await db
|
||||
.select({ payload: growEvents.payload })
|
||||
.from(growEvents)
|
||||
.where(and(
|
||||
eq(growEvents.userId, userId),
|
||||
eq(growEvents.source, CURATOR_SOURCE),
|
||||
eq(growEvents.type, "curator.sprint.invalidated"),
|
||||
))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(20);
|
||||
const alreadyInvalidated = invalidations.some((row) => (
|
||||
row.payload?.staleSprintEventId === latest[0]?.id
|
||||
&& row.payload?.planFingerprint === desiredFingerprint
|
||||
));
|
||||
if (!alreadyInvalidated) {
|
||||
await emitCuratorEvent({
|
||||
userId,
|
||||
type: "curator.sprint.invalidated",
|
||||
payload: {
|
||||
sprintId: sprintIdFor(existingStartDate),
|
||||
staleSprintEventId: latest[0]?.id,
|
||||
staleSprintId: sprintIdFor(existingStartDate),
|
||||
startDate: existingStartDate,
|
||||
reason: "onboarding_plan_changed",
|
||||
previousVariantId: existingVariant,
|
||||
previousAccess: existingAccess,
|
||||
previousDurationDays: existingDuration,
|
||||
previousPlanFingerprint: existingFingerprint,
|
||||
variantId: desiredVariant,
|
||||
access: desired.access,
|
||||
durationDays: desired.durationDays,
|
||||
planFingerprint: desiredFingerprint,
|
||||
version: CURATOR_PLAN_VERSION,
|
||||
},
|
||||
});
|
||||
}
|
||||
// Keep the same calendar sprint identity while replacing its incompatible lineage.
|
||||
replacementStartDate = existingStartDate;
|
||||
}
|
||||
|
||||
const variantId = await inferIcpVariant(userId);
|
||||
const variantId = desiredVariant;
|
||||
const templateSet = templateSetFor(variantId);
|
||||
const planDays = planSeedsForVariant(templateSet, todayDate, desired.durationDays, desired.access);
|
||||
const planDays = planSeedsForVariant(templateSet, replacementStartDate, desired.durationDays, desired.access);
|
||||
const planFingerprint = curatorSprintPlanFingerprint({
|
||||
startDate: replacementStartDate,
|
||||
variantId,
|
||||
durationDays: desired.durationDays,
|
||||
access: desired.access,
|
||||
});
|
||||
await emitCuratorEvent({
|
||||
userId,
|
||||
type: "curator.sprint.started",
|
||||
payload: {
|
||||
startDate: todayDate,
|
||||
sprintId: sprintIdFor(todayDate),
|
||||
startDate: replacementStartDate,
|
||||
sprintId: sprintIdFor(replacementStartDate),
|
||||
durationDays: desired.durationDays,
|
||||
access: desired.access,
|
||||
version: CURATOR_PLAN_VERSION,
|
||||
variantId,
|
||||
icpId: variantId,
|
||||
icpLabel: templateSet.label,
|
||||
planFingerprint,
|
||||
taskPlan: taskPlanForSeeds(replacementStartDate, planDays),
|
||||
},
|
||||
});
|
||||
return {
|
||||
startDate: todayDate,
|
||||
startDate: replacementStartDate,
|
||||
variantId,
|
||||
durationDays: desired.durationDays,
|
||||
access: desired.access,
|
||||
@@ -1263,6 +1397,20 @@ async function buildCuratorSprintInternal(
|
||||
goals: [templateSet.sprintTheme, templateSet.goal],
|
||||
generatedAt: new Date().toISOString(),
|
||||
durationDays: sprintState.durationDays,
|
||||
access: sprintState.access,
|
||||
icpId: sprintState.variantId,
|
||||
planFingerprint: curatorSprintPlanFingerprint({
|
||||
startDate: sprintStartDate,
|
||||
variantId: sprintState.variantId,
|
||||
durationDays: sprintState.durationDays,
|
||||
access: sprintState.access,
|
||||
}),
|
||||
taskPlan: curatorSprintTaskPlan({
|
||||
startDate: sprintStartDate,
|
||||
variantId: sprintState.variantId,
|
||||
durationDays: sprintState.durationDays,
|
||||
access: sprintState.access,
|
||||
}),
|
||||
weeks,
|
||||
days,
|
||||
streak,
|
||||
|
||||
@@ -120,6 +120,13 @@ export const curatorPlanSchema = z.object({
|
||||
goals: z.array(z.string()),
|
||||
generatedAt: z.string(),
|
||||
durationDays: z.union([z.literal(2), z.literal(7)]),
|
||||
access: z.enum(["trial", "full"]),
|
||||
icpId: z.string(),
|
||||
planFingerprint: z.string(),
|
||||
taskPlan: z.array(z.object({
|
||||
dayIndex: z.number().int().min(1),
|
||||
tasks: z.array(z.object({ id: z.string(), title: z.string() })),
|
||||
})),
|
||||
weeks: z.array(curatorWeekSchema).min(1).max(1),
|
||||
days: z.array(curatorPlanDaySchema).min(2).max(7),
|
||||
streak: curatorStreakSchema,
|
||||
|
||||
Reference in New Issue
Block a user