Merge commit 'd4090f560763af57875f8a37696ad2597a45c2d9' into review/pr18

# Conflicts:
#	drizzle/meta/_journal.json
This commit is contained in:
-Puter
2026-07-14 18:07:44 +05:30
20 changed files with 382 additions and 194 deletions
@@ -0,0 +1,4 @@
-- Session external ids are only unique within a user's service namespace.
DROP INDEX IF EXISTS mission_service_sessions_external_idx;
CREATE UNIQUE INDEX IF NOT EXISTS mission_service_sessions_external_idx
ON mission_service_sessions(user_id, service_id, external_id);
+10 -3
View File
@@ -103,15 +103,22 @@
{
"idx": 14,
"version": "7",
"when": 1783987200000,
"tag": "0014_event_outbox",
"when": 1784024806000,
"tag": "0014_session_identity_user_scope",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1783987200000,
"tag": "0015_event_outbox",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1784010000000,
"tag": "0015_rq_score_snapshot",
"tag": "0016_rq_score_snapshot",
"breakpoints": true
}
]
@@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import { allowsDirectServiceCompletion } from "../src/v1/curator/curator-actor.js";
import { v1CuratorRoutes } from "../src/v1/curator/curator-routes.js";
assert.equal(
allowsDirectServiceCompletion(undefined, "matchmaking_matches_reviewed"),
false,
"a recovered or arbitrary task ID must not bypass exact Curator task lookup",
);
assert.equal(allowsDirectServiceCompletion("matchmaking-service", "matchmaking_matches_reviewed"), true);
assert.equal(allowsDirectServiceCompletion("qscore-service", "qscore_review_opened"), true);
assert.equal(allowsDirectServiceCompletion("resume-service", "resume_uploaded"), false);
const serviceImpactRoute = v1CuratorRoutes().routes.find(
(route) => route.method === "POST" && route.path === "/events/service-impact",
);
assert.equal(serviceImpactRoute, undefined, "the false-positive service-impact compatibility route must not be exposed");
console.log("curator direct-completion policy tests passed");
process.exit(0);
@@ -1,14 +1,15 @@
import assert from "node:assert/strict";
import { getCompletionEvents } from "../src/services/service-registry.js";
import { isTaskCompletionEvent, planSeedsForVariant, type CuratorCompletionEvent } from "../src/v1/curator/curator-store.js";
import { isStreakCompletionType, isTaskCompletionEvent, planSeedsForVariant, type CuratorCompletionEvent } from "../src/v1/curator/curator-store.js";
import { templateSetFor } from "../src/v1/curator/task-registry.js";
import type { CuratorIcpId } from "../src/v1/curator/icp-registry.js";
import { canonicalGrowEventType } from "../src/events/normalize.js";
const taskId = "curator-task-1";
const event = (type: string, correlatedTaskId = taskId): CuratorCompletionEvent => ({
type,
payload: {},
correlation: { taskId: correlatedTaskId, curatorTaskId: correlatedTaskId },
correlation: { task_id: correlatedTaskId },
});
const roleplayEvents = getCompletionEvents("roleplay-service");
@@ -18,33 +19,77 @@ assert.equal(isTaskCompletionEvent(taskId, roleplayEvents, event("roleplay.scena
assert.equal(isTaskCompletionEvent(taskId, roleplayEvents, event("roleplay.feedback.generated")), true);
assert.equal(isTaskCompletionEvent(taskId, roleplayEvents, event("roleplay.scenario.completed", "other-task")), false);
const courseEvents = getCompletionEvents("courses-service");
assert.deepEqual(courseEvents, ["course.completed"]);
assert.equal(isTaskCompletionEvent(taskId, courseEvents, event("course.generated")), false);
assert.equal(isTaskCompletionEvent(taskId, courseEvents, event("course.progress.recorded")), false);
assert.equal(isTaskCompletionEvent(taskId, courseEvents, event("course.completed")), true);
const assertCompletionContract = (serviceId: string, accepted: string[], excluded: string[]) => {
const completionEvents = getCompletionEvents(serviceId);
assert.deepEqual(completionEvents, accepted);
for (const type of accepted) {
assert.equal(isTaskCompletionEvent(taskId, completionEvents, event(type)), true, `${serviceId} accepts ${type}`);
assert.equal(isTaskCompletionEvent(taskId, completionEvents, event(type, "other-task")), false, `${serviceId} requires task correlation for ${type}`);
}
for (const type of excluded) {
assert.equal(isTaskCompletionEvent(taskId, completionEvents, event(type)), false, `${serviceId} excludes ${type}`);
}
};
const socialEvents = getCompletionEvents("social-branding-service");
assert.equal(isTaskCompletionEvent(taskId, socialEvents, event("social.post.scheduled")), true);
assert.equal(isTaskCompletionEvent(taskId, socialEvents, event("brand.content.published")), true);
assert.equal(isTaskCompletionEvent(taskId, socialEvents, event("brand.post.drafted")), false);
assertCompletionContract(
"courses-service",
["course.generated", "course.started", "course.progress.recorded", "course.completed"],
[],
);
const resumeEvents = getCompletionEvents("resume-service");
assert.equal(isTaskCompletionEvent(taskId, resumeEvents, event("resume.parsed")), false);
assert.equal(isTaskCompletionEvent(taskId, resumeEvents, event("resume.analysis.completed")), true);
assertCompletionContract(
"social-branding-service",
["brand.account.connected", "brand.post.drafted", "brand.profile.updated", "brand.post.exported", "brand.content.published"],
["social.account.connected", "social.profile.updated", "social.post.scheduled", "social.post.published"],
);
const assessmentEvents = getCompletionEvents("assessment-service");
assert.equal(isTaskCompletionEvent(taskId, assessmentEvents, event("assessment.started")), false);
assert.equal(isTaskCompletionEvent(taskId, assessmentEvents, event("assessment.completed")), true);
assertCompletionContract(
"resume-service",
["resume.uploaded", "resume.parsed", "resume.analysis.completed", "resume.updated", "resume.exported"],
["resume.configured"],
);
const matchmakingEvents = getCompletionEvents("matchmaking-service");
assert.equal(isTaskCompletionEvent(taskId, matchmakingEvents, event("matchmaking.matches.generated")), false);
assert.equal(isTaskCompletionEvent(taskId, matchmakingEvents, event("matchmaking.match.dismissed")), false);
assert.equal(isTaskCompletionEvent(taskId, matchmakingEvents, event("matchmaking.matches.reviewed")), true);
assertCompletionContract(
"matchmaking-service",
[
"matchmaking.feed.viewed",
"matchmaking.match.viewed",
"matchmaking.application.started",
"matchmaking.match.saved",
"matchmaking.matches.reviewed",
"matchmaking.match.applied",
"matchmaking.application.completed",
],
["matchmaking.matches.generated", "matchmaking.match.dismissed"],
);
const qscoreEvents = getCompletionEvents("qscore-service");
assert.equal(isTaskCompletionEvent(taskId, qscoreEvents, event("qscore.review.opened")), false);
assert.equal(isTaskCompletionEvent(taskId, qscoreEvents, event("qscore.weak_driver.reviewed")), true);
assertCompletionContract(
"qscore-service",
["qscore.review.opened", "qscore.weak_driver.reviewed", "qscore.signal.projected", "qscore.updated"],
["qscore.baseline.created"],
);
for (const type of [
"resume.uploaded",
"resume.parsed",
"course.generated",
"course.started",
"course.progress.recorded",
"qscore.review.opened",
"brand.account.connected",
"brand.post.drafted",
"matchmaking.feed.viewed",
"matchmaking.match.viewed",
"matchmaking.application.started",
]) {
assert.equal(isStreakCompletionType(type), true, `${type} must qualify for the broad streak activity rule`);
}
for (const type of ["qscore.baseline.created", "assessment.started", "matchmaking.matches.generated"]) {
assert.equal(isStreakCompletionType(type), false, `${type} must remain non-completing`);
}
assert.equal(canonicalGrowEventType("interview.session_completed"), "interview.session.completed");
assert.equal(canonicalGrowEventType("roleplay.session_completed"), "roleplay.scenario.completed");
const icpIds: CuratorIcpId[] = [
"student_recent_grad",
+42
View File
@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import {
composeTrialDay2,
curatorLogicalDate,
resolveAgedTrialReuse,
} from "../src/v1/curator/streak-utils.js";
assert.equal(curatorLogicalDate(new Date("2026-07-14T18:29:59.999Z")), "2026-07-14");
assert.equal(curatorLogicalDate(new Date("2026-07-14T18:30:00.000Z")), "2026-07-15");
const reuse = resolveAgedTrialReuse({
existing: { startDate: "2026-07-14", version: "icp-v10-static", durationDays: 2, access: "trial" },
desired: { durationDays: 2, access: "trial" },
today: curatorLogicalDate(new Date("2026-07-18T00:00:00.000Z")),
planVersion: "icp-v10-static",
});
assert.equal(reuse.action, "reuse");
assert.equal(reuse.startDate, "2026-07-14");
assert.equal(reuse.activeDayIndex, 2);
const fullDay7 = resolveAgedTrialReuse({
existing: { startDate: "2026-07-14", version: "icp-v10-static", durationDays: 7, access: "full" },
desired: { durationDays: 7, access: "full" },
today: "2026-07-20",
planVersion: "icp-v10-static",
});
assert.equal(fullDay7.action, "reuse", "a full sprint must remain on its seventh day");
assert.equal(fullDay7.activeDayIndex, 7);
const fullDay8 = resolveAgedTrialReuse({
existing: { startDate: "2026-07-14", version: "icp-v10-static", durationDays: 7, access: "full" },
desired: { durationDays: 7, access: "full" },
today: "2026-07-21",
planVersion: "icp-v10-static",
});
assert.equal(fullDay8.action, "restart", "a full sprint may restart only after its seventh day");
const native = [{ id: "native-1" }, { id: "native-2" }, { id: "native-3" }, { id: "native-4" }];
const recovery = { id: "recovery" };
assert.deepEqual(composeTrialDay2("student_recent_grad", 0, native, recovery), [recovery, ...native.slice(0, 3)]);
assert.deepEqual(composeTrialDay2("student_recent_grad", 1, native, recovery), native);
console.log("curator trial progression regression: ok");
@@ -1,3 +1,17 @@
import { normalizeGrowEvent } from "../src/events/normalize.js";
// Mirrors /v1/events/track's accepted body shape without requiring Postgres.
{
const event = normalizeGrowEvent({
source: "interview-service",
type: "interview.session.completed",
correlation: { task_id: "task-nested", keep: "preserve", session_id: "session-1" },
payload: { status: "completed" },
});
if (event.correlation?.task_id !== "task-nested" || event.correlation.keep !== "preserve" || event.correlation.session_id !== "session-1") {
throw new Error("nested correlation fields were not preserved at ingress");
}
}
import assert from "node:assert/strict";
import { routeGrowEventToUserActor } from "../src/events/route-to-user-actor.js";
import { shouldRouteGrowEvent } from "../src/events/record-grow-event.js";
+35 -7
View File
@@ -1,8 +1,33 @@
import assert from "node:assert/strict";
import { correlateEventWithServiceSession, normalizeServiceId, serviceEventExternalIds } from "../src/events/record-grow-event.js";
import { canonicalGrowEventType } from "../src/events/normalize.js";
import { correlateEventWithServiceSession, normalizeServiceId, resolveUniqueSessionUserId, serviceEventExternalIds } from "../src/events/record-grow-event.js";
import { canonicalGrowEventType, normalizeGrowEvent } from "../src/events/normalize.js";
import type { GrowEventEnvelope } from "../src/events/envelope.js";
{
const normalized = normalizeGrowEvent({
source: "interview-service",
type: "interview.session.completed",
correlation: { session_id: "session-1", taskId: "nested-task", keep: "yes" },
task_id: "top-level-task",
payload: { curator_task_id: "payload-task" },
});
assert.equal(normalized.correlation?.session_id, "session-1");
assert.equal(normalized.correlation?.keep, "yes");
assert.equal(normalized.correlation?.task_id, "nested-task");
assert.equal(normalized.correlation?.taskId, undefined);
assert.equal(normalized.correlation?.curatorTaskId, undefined);
}
{
const standalone = normalizeGrowEvent({ source: "social-service", type: "social.account.connected" });
assert.equal(standalone.correlation, undefined);
assert.equal(standalone.type, "brand.account.connected");
}
{
assert.equal(resolveUniqueSessionUserId([{ userId: "user-a" }]), "user-a");
assert.equal(resolveUniqueSessionUserId([{ userId: "user-a" }, { userId: "user-a" }]), "user-a");
assert.equal(resolveUniqueSessionUserId([{ userId: "user-a" }, { userId: "user-b" }]), undefined);
}
const event: GrowEventEnvelope = {
id: "event-1",
userId: "user-a",
@@ -19,15 +44,14 @@ const link = {
missionInstanceId: "sprint-1",
missionId: "curator-sprint",
stageId: "day-1:interview",
metadata: { taskId: "curator-task-1" },
metadata: { task_id: "curator-task-1" },
};
{
const correlated = correlateEventWithServiceSession(event, "user-a", link);
assert.deepEqual(correlated.correlation, {
sessionId: "session-1",
taskId: "curator-task-1",
curatorTaskId: "curator-task-1",
task_id: "curator-task-1",
missionInstanceId: "sprint-1",
missionId: "curator-sprint",
stageId: "day-1:interview",
@@ -43,6 +67,10 @@ const link = {
const foreign = correlateEventWithServiceSession(event, "user-a", { ...link, userId: "user-b" });
assert.strictEqual(foreign, event, "a service session owned by another user must never enrich the event");
}
{
const sessionless = correlateEventWithServiceSession(event, "user-a", undefined);
assert.strictEqual(sessionless, event, "without an exact session link, historical events must not enrich correlation");
}
{
const unresolved = correlateEventWithServiceSession(event, undefined, link);
@@ -53,9 +81,9 @@ const link = {
const explicit = correlateEventWithServiceSession({
...event,
mission: { missionId: "explicit-mission", stageId: "explicit-stage" },
correlation: { sessionId: "session-1", taskId: "explicit-task" },
correlation: { sessionId: "session-1", task_id: "explicit-task" },
}, "user-a", link);
assert.equal(explicit.correlation?.taskId, "explicit-task");
assert.equal(explicit.correlation?.task_id, "explicit-task");
assert.deepEqual(explicit.mission, { missionId: "explicit-mission", stageId: "explicit-stage" });
}
+18 -18
View File
@@ -15,9 +15,9 @@ const TODAY = "2026-07-10";
{
const days = ["2026-07-10", "2026-07-09", "2026-07-08"];
const streak = computeStreakFromDays(days, TODAY);
assert.equal(streak.current, 3, "current streak should be 3 for 3 consecutive days ending today");
assert.equal(streak.longest, 3, "longest should match current when run is unbroken");
assert.equal(streak.lastCompletedDate, "2026-07-10");
assert.equal(streak.current_days, 3, "current streak should be 3 for 3 consecutive days ending today");
assert.equal(streak.longest_days, 3, "longest should match current when run is unbroken");
assert.equal(streak.last_completed_on, "2026-07-10");
}
// ── 2. Same-day duplicate must not inflate the streak ────────────────────────
@@ -30,9 +30,9 @@ const TODAY = "2026-07-10";
// loop checks `days.includes(cursor)` which is set-based, so current stays 2.
// But the longest loop iterates ALL entries including the dup, which could
// inflate longest to 3. The correct behavior: duplicates must not inflate.
assert.equal(streak.current, 2, "duplicate day must not inflate current streak");
assert.equal(streak.current_days, 2, "duplicate day must not inflate current streak");
assert.equal(
streak.longest,
streak.longest_days,
2,
"duplicate day must not inflate longest streak — duplicates should be deduped before counting",
);
@@ -43,8 +43,8 @@ const TODAY = "2026-07-10";
// Completed today and 3 days ago — gap breaks current streak
const days = ["2026-07-10", "2026-07-07"];
const streak = computeStreakFromDays(days, TODAY);
assert.equal(streak.current, 1, "gap resets current streak to 1 (only today)");
assert.equal(streak.longest, 1, "longest should also be 1 with only isolated days");
assert.equal(streak.current_days, 1, "gap resets current streak to 1 (only today)");
assert.equal(streak.longest_days, 1, "longest should also be 1 with only isolated days");
}
// ── 4. Recovery: longest captures a past longer run even if current is broken ─
@@ -59,8 +59,8 @@ const TODAY = "2026-07-10";
"2026-07-01",
];
const streak = computeStreakFromDays(days, TODAY);
assert.equal(streak.current, 1, "current is 1 after a gap even if a longer past run exists");
assert.equal(streak.longest, 5, "longest should capture the 5-day past run");
assert.equal(streak.current_days, 1, "current is 1 after a gap even if a longer past run exists");
assert.equal(streak.longest_days, 5, "longest should capture the 5-day past run");
}
// ── 5. Seven-day eligibility: a 7-day streak counts as eligible ──────────────
@@ -70,25 +70,25 @@ const TODAY = "2026-07-10";
"2026-07-07", "2026-07-06", "2026-07-05", "2026-07-04",
];
const streak = computeStreakFromDays(days, TODAY);
assert.equal(streak.current, 7, "7 consecutive days should yield current=7");
assert.equal(streak.longest, 7, "7 consecutive days should yield longest=7");
assert.ok(streak.current >= 7, "7-day streak eligibility threshold met");
assert.equal(streak.current_days, 7, "7 consecutive days should yield current=7");
assert.equal(streak.longest_days, 7, "7 consecutive days should yield longest=7");
assert.ok(streak.current_days >= 7, "7-day streak eligibility threshold met");
}
// ── 6. Empty days yields zero streak ─────────────────────────────────────────
{
const streak = computeStreakFromDays([], TODAY);
assert.equal(streak.current, 0);
assert.equal(streak.longest, 0);
assert.equal(streak.lastCompletedDate, null);
assert.equal(streak.current_days, 0);
assert.equal(streak.longest_days, 0);
assert.equal(streak.last_completed_on, null);
}
// ── 7. Not completed today but completed yesterday: current is 0 ─────────────
// ── 7. Yesterday keeps the current streak alive until today's IST day closes ─
{
const days = ["2026-07-09"];
const streak = computeStreakFromDays(days, TODAY);
assert.equal(streak.current, 0, "current is 0 if today is not completed");
assert.equal(streak.longest, 1, "longest should still count the 1-day run");
assert.equal(streak.current_days, 1, "yesterday remains current during the one-day grace window");
assert.equal(streak.longest_days, 1, "longest should still count the 1-day run");
}
console.log("streak-policy tests passed");
+1 -1
View File
@@ -429,7 +429,7 @@ export const missionServiceSessions = pgTable(
},
(t) => ({
userIdx: index("mission_service_sessions_user_idx").on(t.userId, t.updatedAt),
externalIdx: uniqueIndex("mission_service_sessions_external_idx").on(t.serviceId, t.externalId),
externalIdx: uniqueIndex("mission_service_sessions_external_idx").on(t.userId, t.serviceId, t.externalId),
missionIdx: index("mission_service_sessions_mission_idx").on(t.missionInstanceId, t.stageId),
}),
);
+21 -3
View File
@@ -27,9 +27,18 @@ function normalizeMission(value: unknown) {
return { instanceId, missionId, stageId };
}
function normalizeCorrelation(value: unknown) {
function normalizeCorrelation(value: unknown, taskAliases: unknown[] = []) {
const record = asRecord(value);
const correlation: Record<string, unknown> = { ...record };
const correlation: Record<string, unknown> = Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined));
delete correlation.taskId;
delete correlation.curatorTaskId;
delete correlation.curator_task_id;
delete correlation.task_id;
// Curator-launched and standalone service events share this ingress, so optional
// task identity is canonicalized once instead of forcing downstream precedence guesses.
const taskId = [...[record.task_id, record.taskId, record.curatorTaskId, record.curator_task_id], ...taskAliases]
.map(getString)
.find((item): item is string => Boolean(item));
const requestId = getString(record.requestId ?? record.request_id);
const sessionId = getString(record.sessionId ?? record.session_id);
const resumeId = getString(record.resumeId ?? record.resume_id);
@@ -38,12 +47,19 @@ function normalizeCorrelation(value: unknown) {
if (sessionId) correlation.sessionId = sessionId;
if (resumeId) correlation.resumeId = resumeId;
if (externalId) correlation.externalId = externalId;
if (taskId) correlation.task_id = taskId;
return Object.keys(correlation).length ? correlation : undefined;
}
function normalizeTaskAliases(raw: Record<string, unknown>, payload: Record<string, unknown>) {
return [raw.task_id, raw.taskId, raw.curatorTaskId, raw.curator_task_id, payload.task_id, payload.taskId, payload.curatorTaskId, payload.curator_task_id];
}
const CANONICAL_EVENT_TYPE: Record<string, string> = {
"interview.review.completed": "interview.feedback.generated",
"interview.session_completed": "interview.session.completed",
"roleplay.session.completed": "roleplay.scenario.completed",
"roleplay.session_completed": "roleplay.scenario.completed",
"roleplay.review.completed": "roleplay.feedback.generated",
"course.video.completed": "course.completed",
"course.lesson.completed": "course.completed",
@@ -52,6 +68,8 @@ const CANONICAL_EVENT_TYPE: Record<string, string> = {
"social.profile.updated": "brand.profile.updated",
"social.post.scheduled": "brand.post.exported",
"social.post.published": "brand.content.published",
"social.content.generated": "brand.post.drafted",
"social.account.connected": "brand.account.connected",
};
export function canonicalGrowEventType(type: string): string {
@@ -66,7 +84,7 @@ export function normalizeGrowEvent(input: unknown, overrides: { userId?: string;
resume_id: raw.resume_id ?? payload.resume_id,
external_id: raw.external_id ?? payload.external_id,
request_id: raw.request_id ?? payload.request_id,
});
}, normalizeTaskAliases(raw, payload));
const subject = normalizeSubject(raw.subject) ?? (() => {
const serviceId = getString(raw.subject_service_id ?? payload.subject_service_id);
const externalId = getString(raw.subject_external_id ?? payload.subject_external_id);
@@ -71,8 +71,7 @@ export async function applyServiceSessionProjection(event: GrowEventRow) {
missionInstanceId,
missionId,
stageId,
taskId: getString(correlation.taskId ?? correlation.curatorTaskId ?? correlation.curator_task_id ?? payload.taskId ?? payload.curatorTaskId ?? payload.curator_task_id),
curatorTaskId: getString(correlation.curatorTaskId ?? correlation.curator_task_id ?? correlation.taskId ?? payload.curatorTaskId ?? payload.curator_task_id ?? payload.taskId),
task_id: getString(correlation.task_id ?? payload.task_id ?? payload.taskId ?? payload.curatorTaskId ?? payload.curator_task_id),
courseId: getString(correlation.courseId ?? payload.courseId ?? payload.course_id),
lessonId: getString(correlation.lessonId ?? payload.lessonId ?? payload.lesson_id),
};
@@ -102,14 +101,18 @@ export async function applyServiceSessionProjection(event: GrowEventRow) {
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [missionServiceSessions.serviceId, missionServiceSessions.externalId],
target: [missionServiceSessions.userId, missionServiceSessions.serviceId, missionServiceSessions.externalId],
set: updateValues,
})
.returning();
// Touch the row if Drizzle ever returns no row for an upsert variant.
if (!row) {
await db.update(missionServiceSessions).set({ updatedAt: new Date() }).where(eq(missionServiceSessions.externalId, externalId));
await db.update(missionServiceSessions).set({ updatedAt: new Date() }).where(and(
eq(missionServiceSessions.userId, event.userId),
eq(missionServiceSessions.serviceId, serviceId),
eq(missionServiceSessions.externalId, externalId),
));
}
return row ?? null;
}
+14 -44
View File
@@ -1,4 +1,4 @@
import { and, desc, eq, or, sql } from "drizzle-orm";
import { and, eq, or } from "drizzle-orm";
import { db } from "../db/client.js";
import { eventOutbox, growEvents, missionServiceSessions, users, type GrowEventRow } from "../db/schema.js";
import { asRecord, getString, type GrowEventEnvelope } from "./envelope.js";
@@ -25,14 +25,14 @@ export function correlateEventWithServiceSession(
): GrowEventEnvelope {
if (!resolvedUserId || !link || link.userId !== resolvedUserId) return event;
const metadata = asRecord(link.metadata);
const taskId = getString(metadata.taskId ?? metadata.curatorTaskId ?? metadata.curator_task_id);
const taskId = getString(metadata.task_id ?? metadata.taskId ?? metadata.curatorTaskId ?? metadata.curator_task_id);
const missionInstanceId = getString(link.missionInstanceId ?? metadata.missionInstanceId ?? metadata.mission_instance_id);
const missionId = getString(link.missionId ?? metadata.missionId ?? metadata.mission_id);
const stageId = getString(link.stageId ?? metadata.stageId ?? metadata.stage_id);
const eventCorrelation = event.correlation ?? {};
const explicitTaskId = getString(eventCorrelation.taskId ?? eventCorrelation.curatorTaskId ?? eventCorrelation.curator_task_id);
const explicitTaskId = getString(eventCorrelation.task_id ?? eventCorrelation.taskId ?? eventCorrelation.curatorTaskId ?? eventCorrelation.curator_task_id);
const correlation = {
...(taskId && !explicitTaskId ? { taskId, curatorTaskId: taskId } : {}),
...(taskId && !explicitTaskId ? { task_id: taskId } : {}),
...(missionInstanceId && !getString(eventCorrelation.missionInstanceId) ? { missionInstanceId } : {}),
...(missionId && !getString(eventCorrelation.missionId) ? { missionId } : {}),
...(stageId && !getString(eventCorrelation.stageId) ? { stageId } : {}),
@@ -79,31 +79,32 @@ export function serviceEventExternalIds(event: GrowEventEnvelope): string[] {
].map(getString).filter((value): value is string => Boolean(value)))];
}
function eventHasExternalId(event: Pick<GrowEventRow, "correlation" | "payload" | "subject">, externalIds: Set<string>) {
return serviceEventExternalIds({ correlation: asRecord(event.correlation), payload: asRecord(event.payload), subject: asRecord(event.subject) } as GrowEventEnvelope)
.some((value) => externalIds.has(value));
}
async function resolveUserId(event: GrowEventEnvelope): Promise<string | undefined> {
if (event.userId) return event.userId;
const externalIds = serviceEventExternalIds(event);
const serviceId = normalizeServiceId(event.source);
if (!externalIds.length) return undefined;
const [row] = await db
const rows = await db
.select({ userId: missionServiceSessions.userId })
.from(missionServiceSessions)
.where(and(
eq(missionServiceSessions.serviceId, serviceId),
inArrayValues(missionServiceSessions.externalId, externalIds),
))
.limit(1);
return row?.userId ?? undefined;
));
return resolveUniqueSessionUserId(rows);
}
function inArrayValues(column: typeof missionServiceSessions.externalId, values: string[]) {
return or(...values.map((value) => eq(column, value)))!;
}
// External service IDs are not globally unique. Ambiguous ownership must remain
// unresolved rather than silently attaching another user's Curator task context.
export function resolveUniqueSessionUserId(rows: Array<{ userId: string }>): string | undefined {
const userIds = new Set(rows.map((row) => row.userId));
return userIds.size === 1 ? [...userIds][0] : undefined;
}
async function resolveServiceSessionLink(event: GrowEventEnvelope, userId: string | undefined): Promise<ServiceSessionLink | undefined> {
if (!userId) return undefined;
const externalIds = serviceEventExternalIds(event);
@@ -124,39 +125,8 @@ async function resolveServiceSessionLink(event: GrowEventEnvelope, userId: strin
inArrayValues(missionServiceSessions.externalId, externalIds),
))
.limit(1);
return row;
const rowMetadata = asRecord(row?.metadata);
const rowTaskId = getString(rowMetadata.taskId ?? rowMetadata.curatorTaskId ?? rowMetadata.curator_task_id);
if (rowTaskId) return row;
const externalIdSet = new Set(externalIds);
const candidates = await db
.select({ source: growEvents.source, correlation: growEvents.correlation, payload: growEvents.payload, mission: growEvents.mission, subject: growEvents.subject })
.from(growEvents)
.where(and(
eq(growEvents.userId, userId),
sql`coalesce(${growEvents.correlation}->>'taskId', ${growEvents.correlation}->>'curatorTaskId', ${growEvents.payload}->>'taskId', ${growEvents.payload}->>'curatorTaskId') is not null`,
))
.orderBy(desc(growEvents.occurredAt))
.limit(100);
const prior = candidates.find((candidate) => (
normalizeServiceId(candidate.source) === serviceId && eventHasExternalId(candidate, externalIdSet)
));
if (!prior) return row;
const priorCorrelation = asRecord(prior.correlation);
const priorPayload = asRecord(prior.payload);
const priorMission = asRecord(prior.mission);
return {
userId,
missionInstanceId: row?.missionInstanceId ?? getString(priorMission.instanceId ?? priorCorrelation.missionInstanceId) ?? null,
missionId: row?.missionId ?? getString(priorMission.missionId ?? priorCorrelation.missionId) ?? null,
stageId: row?.stageId ?? getString(priorMission.stageId ?? priorCorrelation.stageId) ?? null,
metadata: {
...priorPayload,
...priorCorrelation,
...rowMetadata,
},
};
}
export function normalizeServiceId(source: string): string {
+22 -16
View File
@@ -222,7 +222,7 @@ const serviceRegistry: ServiceRecord[] = [
abandonmentEvents: ["service.abandoned", "interview.session.abandoned"],
qscoreSignalIds: ["interview.communication_score", "interview.confidence_score", "readiness.practice"],
analyticsDimensions: ["interview_type", "target_role", "difficulty", "duration_minutes", "overall_score"],
requiredCorrelationFields: ["taskId"],
requiredCorrelationFields: ["task_id"],
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
actionKind: "active_user_action",
},
@@ -301,7 +301,7 @@ const serviceRegistry: ServiceRecord[] = [
abandonmentEvents: ["service.abandoned", "roleplay.scenario.abandoned"],
qscoreSignalIds: ["roleplay.negotiation_confidence", "communication.roleplay", "readiness.practice"],
analyticsDimensions: ["roleplay_type", "target_role", "difficulty", "duration_minutes", "overall_score"],
requiredCorrelationFields: ["taskId"],
requiredCorrelationFields: ["task_id"],
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
actionKind: "active_user_action",
},
@@ -372,7 +372,7 @@ const serviceRegistry: ServiceRecord[] = [
tab: "resumes",
},
toolName: "prepare_resume_upload",
completionEvents: ["resume.analysis.completed", "resume.updated", "resume.exported"],
completionEvents: ["resume.uploaded", "resume.parsed", "resume.analysis.completed", "resume.updated", "resume.exported"],
qscoreSignals: ["proof.resume", "readiness.ats", "profile.skills"],
usage: "Include mission state and optional section when linking into resume work.",
},
@@ -385,11 +385,11 @@ const serviceRegistry: ServiceRecord[] = [
openEvents: ["task.opened"],
startedEvents: ["service.started", "resume.uploaded"],
configuredEvents: ["service.configured", "resume.parsed"],
completionEvents: ["service.completed", "resume.analysis.completed", "resume.updated", "resume.exported"],
completionEvents: ["service.completed", "resume.uploaded", "resume.parsed", "resume.analysis.completed", "resume.updated", "resume.exported"],
abandonmentEvents: ["service.abandoned"],
qscoreSignalIds: ["resume.ats_score", "resume.profile_completeness", "proof.resume"],
analyticsDimensions: ["resume_id", "version_id", "ats_score", "role_fit_score", "updated_sections"],
requiredCorrelationFields: ["taskId"],
requiredCorrelationFields: ["task_id"],
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
actionKind: "active_user_action",
},
@@ -470,7 +470,7 @@ const serviceRegistry: ServiceRecord[] = [
abandonmentEvents: ["service.abandoned"],
qscoreSignalIds: ["proof.cover_letter", "readiness.application"],
analyticsDimensions: ["cover_letter_id", "role_fit_score", "export_format"],
requiredCorrelationFields: ["taskId"],
requiredCorrelationFields: ["task_id"],
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
actionKind: "active_user_action",
},
@@ -524,7 +524,7 @@ const serviceRegistry: ServiceRecord[] = [
start: "Start course",
},
toolName: "prepare_course_handoff",
completionEvents: ["course.completed"],
completionEvents: ["course.generated", "course.started", "course.progress.recorded", "course.completed"],
qscoreSignals: ["courses.started", "courses.completed", "courses.completion_rate"],
usage: "Use for skill-gap tasks that should become learning work.",
},
@@ -534,11 +534,11 @@ const serviceRegistry: ServiceRecord[] = [
openEvents: ["task.opened"],
startedEvents: ["service.started", "course.started"],
configuredEvents: ["course.generated"],
completionEvents: ["service.completed", "course.completed"],
completionEvents: ["service.completed", "course.generated", "course.started", "course.progress.recorded", "course.completed"],
abandonmentEvents: ["service.abandoned"],
qscoreSignalIds: ["courses.started", "courses.completed", "courses.completion_rate", "courses.difficulty", "courses.pathway_relevance"],
analyticsDimensions: ["course_id", "lesson_id", "progress_percentage", "weakness_tags"],
requiredCorrelationFields: ["taskId"],
requiredCorrelationFields: ["task_id"],
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
actionKind: "active_user_action",
},
@@ -597,7 +597,7 @@ const serviceRegistry: ServiceRecord[] = [
abandonmentEvents: ["service.abandoned"],
qscoreSignalIds: ["skills.assessment", "readiness.measurement"],
analyticsDimensions: ["assessment_id", "skill_area", "score", "weakness_tags"],
requiredCorrelationFields: ["taskId"],
requiredCorrelationFields: ["task_id"],
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
actionKind: "measurement_review",
},
@@ -646,6 +646,9 @@ const serviceRegistry: ServiceRecord[] = [
},
toolName: "prepare_matchmaking_handoff",
completionEvents: [
"matchmaking.feed.viewed",
"matchmaking.match.viewed",
"matchmaking.application.started",
"matchmaking.match.saved",
"matchmaking.matches.reviewed",
"matchmaking.match.applied",
@@ -665,6 +668,9 @@ const serviceRegistry: ServiceRecord[] = [
configuredEvents: [],
completionEvents: [
"service.completed",
"matchmaking.feed.viewed",
"matchmaking.match.viewed",
"matchmaking.application.started",
"matchmaking.match.saved",
"matchmaking.matches.reviewed",
"matchmaking.match.applied",
@@ -673,7 +679,7 @@ const serviceRegistry: ServiceRecord[] = [
abandonmentEvents: ["service.abandoned"],
qscoreSignalIds: ["matching.jobs_viewed", "matching.applications_submitted", "matching.application_quality", "matching.match_rate"],
analyticsDimensions: ["match_count", "role_ids", "role_fit", "saved_count", "dismissed_reason"],
requiredCorrelationFields: ["taskId"],
requiredCorrelationFields: ["task_id"],
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
actionKind: "active_user_action",
},
@@ -717,7 +723,7 @@ const serviceRegistry: ServiceRecord[] = [
review: "Review RQ Score",
},
toolName: "prepare_qscore_review",
completionEvents: ["qscore.weak_driver.reviewed", "qscore.signal.projected", "qscore.updated"],
completionEvents: ["qscore.review.opened", "qscore.weak_driver.reviewed", "qscore.signal.projected", "qscore.updated"],
qscoreSignals: ["qscore.updated", "qscore.signal.projected"],
usage: "Use for measurement and projected readiness review tasks.",
},
@@ -727,7 +733,7 @@ const serviceRegistry: ServiceRecord[] = [
openEvents: ["qscore.review.opened"],
startedEvents: ["service.started"],
configuredEvents: ["qscore.baseline.created"],
completionEvents: ["qscore.signal.projected", "qscore.updated", "qscore.weak_driver.reviewed"],
completionEvents: ["qscore.review.opened", "qscore.weak_driver.reviewed", "qscore.signal.projected", "qscore.updated"],
abandonmentEvents: [],
qscoreSignalIds: ["qscore.updated", "qscore.signal.projected"],
analyticsDimensions: ["score_before", "score_after", "signal_id", "delta", "trend_label"],
@@ -777,7 +783,7 @@ const serviceRegistry: ServiceRecord[] = [
review: "Review profile",
},
toolName: "prepare_social_branding_handoff",
completionEvents: ["brand.post.exported", "brand.profile.updated", "brand.content.published", "social.profile.updated", "social.post.scheduled", "social.post.published"],
completionEvents: ["brand.account.connected", "brand.post.drafted", "brand.profile.updated", "brand.post.exported", "brand.content.published"],
qscoreSignals: ["profile.linkedin", "proof.visibility", "networking.brand"],
usage: "Use for profile visibility, LinkedIn cleanup, and social proof tasks.",
},
@@ -787,11 +793,11 @@ const serviceRegistry: ServiceRecord[] = [
openEvents: ["task.opened"],
startedEvents: ["service.started"],
configuredEvents: ["brand.post.drafted"],
completionEvents: ["service.completed", "brand.post.exported", "brand.profile.updated", "brand.content.published", "social.profile.updated", "social.post.scheduled", "social.post.published"],
completionEvents: ["service.completed", "brand.account.connected", "brand.post.drafted", "brand.profile.updated", "brand.post.exported", "brand.content.published"],
abandonmentEvents: ["service.abandoned"],
qscoreSignalIds: ["brand.visibility_signal", "profile.linkedin", "proof.visibility"],
analyticsDimensions: ["content_type", "channel", "draft_id", "topic", "profile_field_updated"],
requiredCorrelationFields: ["taskId"],
requiredCorrelationFields: ["task_id"],
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
actionKind: "active_user_action",
},
+8 -21
View File
@@ -35,6 +35,13 @@ function taskActionDate(input: { taskId: string; date?: string }) {
return input.date ?? dateFromTaskId(input.taskId) ?? todayIsoDate();
}
export function allowsDirectServiceCompletion(serviceId: string | undefined, reason: string) {
return (
(serviceId === "qscore-service" && reason === "qscore_review_opened") ||
(serviceId === "matchmaking-service" && reason === "matchmaking_matches_reviewed")
);
}
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;
@@ -102,18 +109,8 @@ export const curatorService = {
const date = taskActionDate(input);
const task = (await buildCuratorTasks(input.userId, date)).find((item) => item.id === input.taskId);
const reason = input.reason ?? "subtasks_completed";
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");
const allowDirectServiceCompletion = allowsDirectServiceCompletion(task.serviceId, reason);
if (task.serviceId && !allowDirectServiceCompletion) {
throw new Error("curator_service_task_requires_service_event");
}
@@ -126,11 +123,6 @@ export const curatorService = {
return { task: { ...task, status: "completed" as const }, eventId: event.id };
},
async recordServiceImpact(input: { userId: string; eventId: string }) {
const streak = await buildCuratorStreak(input.userId);
const sprint = await buildCuratorSprint(input.userId, todayIsoDate());
return { matched: true, completedTasks: sprint.todayTasks, streak, sprint };
},
async applyImprovementSignals(input: { userId: string; date: string; signals: CuratorImprovementSignal[] }) {
for (const signal of input.signals) {
@@ -185,11 +177,6 @@ export const curatorActor = actor({
c.state.lastEventId = result.eventId;
return result;
},
recordServiceImpact: async (c, input: Parameters<typeof curatorService.recordServiceImpact>[0]) => {
touch(c, input);
c.state.lastEventId = input.eventId;
return curatorService.recordServiceImpact(input);
},
applyImprovementSignals: async (c, input: Parameters<typeof curatorService.applyImprovementSignals>[0]) => {
touch(c, input);
return curatorService.applyImprovementSignals(input);
-5
View File
@@ -85,11 +85,6 @@ export function v1CuratorRoutes() {
return c.json(await getCuratorActor(userId).completeTask({ userId, taskId: c.req.param("taskId"), date: c.req.query("date"), reason: body.reason }));
});
app.post("/events/service-impact", async (c) => {
const userId = c.get("userId");
const body = z.object({ eventId: z.string() }).parse(await c.req.json());
return c.json(await getCuratorActor(userId).recordServiceImpact({ userId, eventId: body.eventId }));
});
app.get("/state", async (c) => {
const userId = c.get("userId");
+42 -38
View File
@@ -34,10 +34,14 @@ import {
} from "./task-registry.js";
import { kpiDefinitionsForIcp } from "./kpi-registry.js";
import { outcomeDefinitionsForIcp } from "./outcome-report-registry.js";
import { addDaysIso, computeStreakFromDays, todayIso } from "./streak-utils.js";
import { activeCuratorDayIndex, addDaysIso, composeTrialDay2, computeStreakFromDays, curatorLogicalDate, resolveAgedTrialReuse, todayIso } from "./streak-utils.js";
// A streak day represents meaningful correlated activity, not necessarily a
// terminal artifact. Keep this intentionally broader than full task completion.
const VALID_COMPLETION_TYPES = [
"service.completed",
"resume.uploaded",
"resume.parsed",
"resume.analysis.completed",
"resume.updated",
"resume.exported",
@@ -46,23 +50,33 @@ const VALID_COMPLETION_TYPES = [
"roleplay.scenario.completed",
"roleplay.feedback.generated",
"assessment.completed",
"course.generated",
"course.started",
"course.progress.recorded",
"course.completed",
"brand.account.connected",
"brand.post.drafted",
"brand.post.exported",
"brand.profile.updated",
"brand.content.published",
"social.profile.updated",
"social.post.scheduled",
"social.post.published",
"matchmaking.feed.viewed",
"matchmaking.match.viewed",
"matchmaking.application.started",
"matchmaking.match.saved",
"matchmaking.matches.reviewed",
"matchmaking.match.applied",
"matchmaking.application.completed",
"qscore.review.opened",
"qscore.weak_driver.reviewed",
"qscore.updated",
"qscore.signal.projected",
"curator.task.completed",
] as const;
export function isStreakCompletionType(type: string) {
return (VALID_COMPLETION_TYPES as readonly string[]).includes(type);
}
const CURATOR_SOURCE = "curator-v1";
const CURATOR_PLAN_VERSION = "icp-v10-static";
const FULL_SPRINT_DURATION_DAYS = 7;
@@ -195,6 +209,7 @@ function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
function sprintIdFor(startDate: string) {
return `curator-sprint:${CURATOR_PLAN_VERSION}:${startDate}`;
}
@@ -693,7 +708,7 @@ function clonePlanDay(day: PlanDaySeed): PlanDaySeed {
async function loadSprintState(
userId: string,
todayDate = todayIso(),
todayDate = curatorLogicalDate(),
onboardingContextOverride?: Record<string, unknown>,
): Promise<SprintState> {
const savedOnboarding = await db.query.onboarding.findFirst({
@@ -737,9 +752,17 @@ async function loadSprintState(
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 reuseDecision = existingStartDate && existingVersion && existingDuration && existingAccess
? resolveAgedTrialReuse({
existing: { startDate: existingStartDate, version: existingVersion, durationDays: existingDuration, access: existingAccess },
desired,
today: todayDate,
planVersion: CURATOR_PLAN_VERSION,
})
: undefined;
const activeExisting = Boolean(
!rebaseExistingForCompletion && existingStartDate && existingVersion === CURATOR_PLAN_VERSION && existingVariant
&& existingDuration && existingAccess && calendarDiffDays(existingStartDate, todayDate) >= 0 && calendarDiffDays(existingStartDate, todayDate) < existingDuration,
&& existingDuration && existingAccess && reuseDecision?.action === "reuse",
);
let replacementStartDate = todayDate;
@@ -861,15 +884,7 @@ async function loadCurrentQScore(userId: string) {
export type CuratorCompletionEvent = Pick<GrowEventRow, "type" | "payload" | "correlation">;
export function isTaskCompletionEvent(taskId: string, completionEvents: string[], row: CuratorCompletionEvent) {
const payload = row.payload ?? {};
const correlation = row.correlation ?? {};
const eventTaskId =
payload.taskId ??
payload.curatorTaskId ??
payload.curator_task_id ??
correlation.taskId ??
correlation.curatorTaskId ??
correlation.curator_task_id;
const eventTaskId = row.correlation?.task_id;
return eventTaskId === taskId && (
row.type === "curator.task.completed" ||
row.type === "service.completed" ||
@@ -901,16 +916,8 @@ function hasRecentServiceSignal(rows: Awaited<ReturnType<typeof loadRecentContex
return rows.some((row) => pattern.test(`${row.source} ${row.type} ${eventText(row.payload)}`.toLowerCase()));
}
function taskEventId(row: Pick<Awaited<ReturnType<typeof loadRecentContextRows>>[number], "payload" | "correlation">) {
const payload = row.payload ?? {};
const correlation = row.correlation ?? {};
const value =
payload.taskId ??
payload.curatorTaskId ??
payload.curator_task_id ??
correlation.taskId ??
correlation.curatorTaskId ??
correlation.curator_task_id;
function taskEventId(row: Pick<Awaited<ReturnType<typeof loadRecentContextRows>>[number], "correlation">) {
const value = row.correlation?.task_id;
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
@@ -995,10 +1002,7 @@ function trialRecoveryPlanDay(
...current,
focus: recovery.focus,
outcome: recovery.outcome,
plannedTasks: [
recoveryTask,
...current.plannedTasks.filter((task) => !task.isRecoveryPractice).slice(0, 3),
],
plannedTasks: composeTrialDay2(variantId, 0, current.plannedTasks.filter((task) => !task.isRecoveryPractice), recoveryTask),
generationStatus: "adapted",
adaptationReason: `trial day ${missedDayIndex} missed; assigned ${recovery.serviceId} practice recovery`,
hasRecoveryPractice: true,
@@ -1260,10 +1264,10 @@ function buildTasksForPlanDay(
));
}
export async function buildCuratorTasks(userId: string, date = todayIso()): Promise<CuratorTask[]> {
export async function buildCuratorTasks(userId: string, date = curatorLogicalDate()): Promise<CuratorTask[]> {
const sprintState = await loadSprintState(userId, date);
const sprintStartDate = sprintState.startDate;
const dayIndex = clamp(calendarDiffDays(sprintStartDate, date) + 1, 1, sprintState.durationDays);
const dayIndex = activeCuratorDayIndex(sprintStartDate, date, sprintState.durationDays);
const completionRows = await loadCompletionRows(userId, sprintStartDate);
const recentRows = await loadRecentContextRows(userId, sprintStartDate);
const currentQScore = await loadCurrentQScore(userId);
@@ -1291,7 +1295,7 @@ export async function buildCuratorStreak(userId: string): Promise<CuratorStreak>
.where(and(
eq(growEvents.userId, userId),
inArray(growEvents.type, [...VALID_COMPLETION_TYPES]),
sql`coalesce(${growEvents.correlation}->>'taskId', ${growEvents.correlation}->>'curatorTaskId', ${growEvents.payload}->>'taskId', ${growEvents.payload}->>'curatorTaskId') is not null`,
sql`${growEvents.correlation}->>'task_id' is not null`,
))
.groupBy(sql`to_char(${growEvents.occurredAt} AT TIME ZONE 'Asia/Kolkata', 'YYYY-MM-DD')`)
.orderBy(sql`to_char(${growEvents.occurredAt} AT TIME ZONE 'Asia/Kolkata', 'YYYY-MM-DD') desc`)
@@ -1322,7 +1326,7 @@ type CuratorSprintBuildOptions = {
async function buildCuratorSprintInternal(
userId: string,
focusDate = todayIso(),
focusDate = curatorLogicalDate(),
options: CuratorSprintBuildOptions = {},
): Promise<CuratorSprintResponse> {
const sprintState = await loadSprintState(userId, focusDate, options.onboardingContext);
@@ -1333,7 +1337,7 @@ async function buildCuratorSprintInternal(
const recentRows = await loadRecentContextRows(userId, sprintStartDate);
const currentQScore = await loadCurrentQScore(userId);
const userContext = await buildCuratorUserContext(userId);
const activeDayIndex = clamp(calendarDiffDays(sprintStartDate, focusDate) + 1, 1, sprintState.durationDays);
const activeDayIndex = activeCuratorDayIndex(sprintStartDate, focusDate, sprintState.durationDays);
const planDays = adaptCurrentDayPlan(
sprintStartDate,
sprintState.variantId,
@@ -1461,7 +1465,7 @@ export async function buildCuratorPlan(userId: string, input: { startDate: strin
export async function buildCuratorSprint(
userId: string,
date = todayIso(),
date = curatorLogicalDate(),
options: CuratorSprintBuildOptions = {},
): Promise<CuratorSprintResponse> {
return buildCuratorSprintInternal(userId, date, options);
@@ -1576,7 +1580,7 @@ async function saveSprintReport(report: CuratorSprintReport) {
export async function buildCuratorSprintReport(
userId: string,
date = todayIso(),
date = curatorLogicalDate(),
options: CuratorSprintReportBuildOptions = {},
): Promise<CuratorSprintReport> {
const sprintState = await loadSprintState(userId, date);
@@ -1619,7 +1623,7 @@ export async function buildCuratorSprintReport(
}
export async function buildServiceCurationPreview(input: ServiceCurationPreviewInput) {
const startDate = input.startDate ?? todayIso();
const startDate = input.startDate ?? curatorLogicalDate();
const inferredIcpId = input.icpId ?? await inferIcpVariant(input.userId);
const templateSet = templateSetFor(inferredIcpId);
const userContext = {
@@ -1675,5 +1679,5 @@ export async function listCuratorRegistryCapabilities() {
}
export function todayIsoDate() {
return todayIso();
return curatorLogicalDate();
}
+44
View File
@@ -17,12 +17,56 @@ export function indiaTodayIso(date = new Date()): string {
}).format(date);
}
// Timestamps stay UTC; only the product-day boundary is interpreted for India.
export function curatorLogicalDate(date = new Date()): string {
return indiaTodayIso(date);
}
export function addDaysIso(startDate: string, days: number): string {
const date = new Date(`${startDate}T00:00:00.000Z`);
date.setUTCDate(date.getUTCDate() + days);
return todayIso(date);
}
export function activeCuratorDayIndex(startDate: string, now: Date | string, durationDays: 2 | 7): number {
const focusDate = typeof now === "string" ? now : curatorLogicalDate(now);
const start = new Date(`${startDate}T00:00:00.000Z`).getTime();
const end = new Date(`${focusDate}T00:00:00.000Z`).getTime();
return Math.max(1, Math.min(durationDays, Math.round((end - start) / 86400000) + 1));
}
export type AgedTrialReuseInput = {
existing: { startDate: string; version: string; durationDays: 2 | 7; access: "trial" | "full" };
desired: { durationDays: 2 | 7; access: "trial" | "full" };
today: string;
planVersion: string;
};
export function resolveAgedTrialReuse(input: AgedTrialReuseInput) {
const compatible = input.existing.version === input.planVersion
&& input.existing.durationDays === input.desired.durationDays
&& input.existing.access === input.desired.access;
const start = new Date(`${input.existing.startDate}T00:00:00.000Z`).getTime();
const end = new Date(`${input.today}T00:00:00.000Z`).getTime();
const elapsedDayIndex = Math.max(1, Math.round((end - start) / 86400000) + 1);
const activeDayIndex = Math.min(input.existing.durationDays, elapsedDayIndex);
// Trials stay parked on Day 2 for QA/product continuation. Full sprints retain
// every entitled day and restart only after their complete seven-day window.
const reusable = compatible && (
input.existing.access === "trial" || elapsedDayIndex <= input.existing.durationDays
);
return {
action: reusable ? "reuse" as const : "restart" as const,
startDate: reusable ? input.existing.startDate : input.today,
activeDayIndex: reusable ? activeDayIndex : 1,
};
}
export function composeTrialDay2<T>(variant: string, previousCompletedCount: number, nativeTasks: T[], recoveryTask: T): T[] {
void variant;
return previousCompletedCount === 0 ? [recoveryTask, ...nativeTasks.slice(0, 3)] : nativeTasks;
}
/**
* Pure streak computation from a descending-sorted list of completion day
* strings (YYYY-MM-DD). Extracted so streak policies (current/longest,
+11 -10
View File
@@ -27,6 +27,7 @@ const eventTrackSchema = z.object({
dedupeKey: z.string().optional(),
dedupe_key: z.string().optional(),
taskId: z.string().optional(),
task_id: z.string().optional(),
curatorTaskId: z.string().optional(),
curator_task_id: z.string().optional(),
serviceId: z.string().optional(),
@@ -46,20 +47,20 @@ export function v1EventRoutes() {
const body = eventTrackSchema.parse(await c.req.json());
const type = body.type ?? body.action;
if (!type) return c.json({ error: "event_type_required" }, 400);
const payload = {
const nestedCorrelation = body.correlation ?? {};
const nestedTaskId = nestedCorrelation.task_id ?? nestedCorrelation.taskId ?? nestedCorrelation.curatorTaskId ?? nestedCorrelation.curator_task_id;
const taskId = nestedTaskId ?? body.taskId ?? body.task_id ?? body.curatorTaskId ?? body.curator_task_id;
const payload = compactRecord({
...(body.payload ?? {}),
...(body.metadata ? { metadata: body.metadata } : {}),
taskId: body.taskId,
curatorTaskId: body.curatorTaskId ?? body.curator_task_id ?? body.taskId,
serviceId: body.serviceId ?? body.service_id,
...(taskId ? { task_id: taskId } : {}),
...(body.serviceId ?? body.service_id ? { serviceId: body.serviceId ?? body.service_id } : {}),
status: (body.payload?.status ?? body.metadata?.status) as unknown,
};
});
const correlation = compactRecord({
...(body.correlation ?? {}),
taskId: body.taskId,
curatorTaskId: body.curatorTaskId ?? body.curator_task_id ?? body.taskId,
serviceId: body.serviceId ?? body.service_id,
...nestedCorrelation,
...(!nestedTaskId && taskId ? { task_id: taskId } : {}),
...(body.serviceId ?? body.service_id ? { serviceId: body.serviceId ?? body.service_id } : {}),
});
const { event, inserted } = await recordGrowEventWithResult({