fix(curator): stabilize two-day trial progression
This commit is contained in:
@@ -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);
|
||||
@@ -99,6 +99,13 @@
|
||||
"when": 1783801800000,
|
||||
"tag": "0013_onboarding",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1784024806000,
|
||||
"tag": "0014_session_identity_user_scope",
|
||||
"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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -399,7 +399,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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 { growEvents, missionServiceSessions, users, type GrowEventRow } from "../db/schema.js";
|
||||
import { asRecord, getString, type GrowEventEnvelope } from "./envelope.js";
|
||||
@@ -23,14 +23,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 } : {}),
|
||||
@@ -77,31 +77,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);
|
||||
@@ -122,39 +123,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 {
|
||||
|
||||
@@ -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 QScore",
|
||||
},
|
||||
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",
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user