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,
|
"when": 1783801800000,
|
||||||
"tag": "0013_onboarding",
|
"tag": "0013_onboarding",
|
||||||
"breakpoints": true
|
"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 assert from "node:assert/strict";
|
||||||
import { getCompletionEvents } from "../src/services/service-registry.js";
|
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 { templateSetFor } from "../src/v1/curator/task-registry.js";
|
||||||
import type { CuratorIcpId } from "../src/v1/curator/icp-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 taskId = "curator-task-1";
|
||||||
const event = (type: string, correlatedTaskId = taskId): CuratorCompletionEvent => ({
|
const event = (type: string, correlatedTaskId = taskId): CuratorCompletionEvent => ({
|
||||||
type,
|
type,
|
||||||
payload: {},
|
payload: {},
|
||||||
correlation: { taskId: correlatedTaskId, curatorTaskId: correlatedTaskId },
|
correlation: { task_id: correlatedTaskId },
|
||||||
});
|
});
|
||||||
|
|
||||||
const roleplayEvents = getCompletionEvents("roleplay-service");
|
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.feedback.generated")), true);
|
||||||
assert.equal(isTaskCompletionEvent(taskId, roleplayEvents, event("roleplay.scenario.completed", "other-task")), false);
|
assert.equal(isTaskCompletionEvent(taskId, roleplayEvents, event("roleplay.scenario.completed", "other-task")), false);
|
||||||
|
|
||||||
const courseEvents = getCompletionEvents("courses-service");
|
const assertCompletionContract = (serviceId: string, accepted: string[], excluded: string[]) => {
|
||||||
assert.deepEqual(courseEvents, ["course.completed"]);
|
const completionEvents = getCompletionEvents(serviceId);
|
||||||
assert.equal(isTaskCompletionEvent(taskId, courseEvents, event("course.generated")), false);
|
assert.deepEqual(completionEvents, accepted);
|
||||||
assert.equal(isTaskCompletionEvent(taskId, courseEvents, event("course.progress.recorded")), false);
|
for (const type of accepted) {
|
||||||
assert.equal(isTaskCompletionEvent(taskId, courseEvents, event("course.completed")), true);
|
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");
|
assertCompletionContract(
|
||||||
assert.equal(isTaskCompletionEvent(taskId, socialEvents, event("social.post.scheduled")), true);
|
"courses-service",
|
||||||
assert.equal(isTaskCompletionEvent(taskId, socialEvents, event("brand.content.published")), true);
|
["course.generated", "course.started", "course.progress.recorded", "course.completed"],
|
||||||
assert.equal(isTaskCompletionEvent(taskId, socialEvents, event("brand.post.drafted")), false);
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const resumeEvents = getCompletionEvents("resume-service");
|
assertCompletionContract(
|
||||||
assert.equal(isTaskCompletionEvent(taskId, resumeEvents, event("resume.parsed")), false);
|
"social-branding-service",
|
||||||
assert.equal(isTaskCompletionEvent(taskId, resumeEvents, event("resume.analysis.completed")), true);
|
["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");
|
assertCompletionContract(
|
||||||
assert.equal(isTaskCompletionEvent(taskId, assessmentEvents, event("assessment.started")), false);
|
"resume-service",
|
||||||
assert.equal(isTaskCompletionEvent(taskId, assessmentEvents, event("assessment.completed")), true);
|
["resume.uploaded", "resume.parsed", "resume.analysis.completed", "resume.updated", "resume.exported"],
|
||||||
|
["resume.configured"],
|
||||||
|
);
|
||||||
|
|
||||||
const matchmakingEvents = getCompletionEvents("matchmaking-service");
|
assertCompletionContract(
|
||||||
assert.equal(isTaskCompletionEvent(taskId, matchmakingEvents, event("matchmaking.matches.generated")), false);
|
"matchmaking-service",
|
||||||
assert.equal(isTaskCompletionEvent(taskId, matchmakingEvents, event("matchmaking.match.dismissed")), false);
|
[
|
||||||
assert.equal(isTaskCompletionEvent(taskId, matchmakingEvents, event("matchmaking.matches.reviewed")), true);
|
"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");
|
assertCompletionContract(
|
||||||
assert.equal(isTaskCompletionEvent(taskId, qscoreEvents, event("qscore.review.opened")), false);
|
"qscore-service",
|
||||||
assert.equal(isTaskCompletionEvent(taskId, qscoreEvents, event("qscore.weak_driver.reviewed")), true);
|
["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[] = [
|
const icpIds: CuratorIcpId[] = [
|
||||||
"student_recent_grad",
|
"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 assert from "node:assert/strict";
|
||||||
import { routeGrowEventToUserActor } from "../src/events/route-to-user-actor.js";
|
import { routeGrowEventToUserActor } from "../src/events/route-to-user-actor.js";
|
||||||
import { shouldRouteGrowEvent } from "../src/events/record-grow-event.js";
|
import { shouldRouteGrowEvent } from "../src/events/record-grow-event.js";
|
||||||
|
|||||||
@@ -1,8 +1,33 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { correlateEventWithServiceSession, normalizeServiceId, serviceEventExternalIds } from "../src/events/record-grow-event.js";
|
import { correlateEventWithServiceSession, normalizeServiceId, resolveUniqueSessionUserId, serviceEventExternalIds } from "../src/events/record-grow-event.js";
|
||||||
import { canonicalGrowEventType } from "../src/events/normalize.js";
|
import { canonicalGrowEventType, normalizeGrowEvent } from "../src/events/normalize.js";
|
||||||
import type { GrowEventEnvelope } from "../src/events/envelope.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 = {
|
const event: GrowEventEnvelope = {
|
||||||
id: "event-1",
|
id: "event-1",
|
||||||
userId: "user-a",
|
userId: "user-a",
|
||||||
@@ -19,15 +44,14 @@ const link = {
|
|||||||
missionInstanceId: "sprint-1",
|
missionInstanceId: "sprint-1",
|
||||||
missionId: "curator-sprint",
|
missionId: "curator-sprint",
|
||||||
stageId: "day-1:interview",
|
stageId: "day-1:interview",
|
||||||
metadata: { taskId: "curator-task-1" },
|
metadata: { task_id: "curator-task-1" },
|
||||||
};
|
};
|
||||||
|
|
||||||
{
|
{
|
||||||
const correlated = correlateEventWithServiceSession(event, "user-a", link);
|
const correlated = correlateEventWithServiceSession(event, "user-a", link);
|
||||||
assert.deepEqual(correlated.correlation, {
|
assert.deepEqual(correlated.correlation, {
|
||||||
sessionId: "session-1",
|
sessionId: "session-1",
|
||||||
taskId: "curator-task-1",
|
task_id: "curator-task-1",
|
||||||
curatorTaskId: "curator-task-1",
|
|
||||||
missionInstanceId: "sprint-1",
|
missionInstanceId: "sprint-1",
|
||||||
missionId: "curator-sprint",
|
missionId: "curator-sprint",
|
||||||
stageId: "day-1:interview",
|
stageId: "day-1:interview",
|
||||||
@@ -43,6 +67,10 @@ const link = {
|
|||||||
const foreign = correlateEventWithServiceSession(event, "user-a", { ...link, userId: "user-b" });
|
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");
|
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);
|
const unresolved = correlateEventWithServiceSession(event, undefined, link);
|
||||||
@@ -53,9 +81,9 @@ const link = {
|
|||||||
const explicit = correlateEventWithServiceSession({
|
const explicit = correlateEventWithServiceSession({
|
||||||
...event,
|
...event,
|
||||||
mission: { missionId: "explicit-mission", stageId: "explicit-stage" },
|
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);
|
}, "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" });
|
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 days = ["2026-07-10", "2026-07-09", "2026-07-08"];
|
||||||
const streak = computeStreakFromDays(days, TODAY);
|
const streak = computeStreakFromDays(days, TODAY);
|
||||||
assert.equal(streak.current, 3, "current streak should be 3 for 3 consecutive days ending today");
|
assert.equal(streak.current_days, 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.longest_days, 3, "longest should match current when run is unbroken");
|
||||||
assert.equal(streak.lastCompletedDate, "2026-07-10");
|
assert.equal(streak.last_completed_on, "2026-07-10");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 2. Same-day duplicate must not inflate the streak ────────────────────────
|
// ── 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.
|
// 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
|
// But the longest loop iterates ALL entries including the dup, which could
|
||||||
// inflate longest to 3. The correct behavior: duplicates must not inflate.
|
// 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(
|
assert.equal(
|
||||||
streak.longest,
|
streak.longest_days,
|
||||||
2,
|
2,
|
||||||
"duplicate day must not inflate longest streak — duplicates should be deduped before counting",
|
"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
|
// Completed today and 3 days ago — gap breaks current streak
|
||||||
const days = ["2026-07-10", "2026-07-07"];
|
const days = ["2026-07-10", "2026-07-07"];
|
||||||
const streak = computeStreakFromDays(days, TODAY);
|
const streak = computeStreakFromDays(days, TODAY);
|
||||||
assert.equal(streak.current, 1, "gap resets current streak to 1 (only today)");
|
assert.equal(streak.current_days, 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.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 ─
|
// ── 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",
|
"2026-07-01",
|
||||||
];
|
];
|
||||||
const streak = computeStreakFromDays(days, TODAY);
|
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.current_days, 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.longest_days, 5, "longest should capture the 5-day past run");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 5. Seven-day eligibility: a 7-day streak counts as eligible ──────────────
|
// ── 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",
|
"2026-07-07", "2026-07-06", "2026-07-05", "2026-07-04",
|
||||||
];
|
];
|
||||||
const streak = computeStreakFromDays(days, TODAY);
|
const streak = computeStreakFromDays(days, TODAY);
|
||||||
assert.equal(streak.current, 7, "7 consecutive days should yield current=7");
|
assert.equal(streak.current_days, 7, "7 consecutive days should yield current=7");
|
||||||
assert.equal(streak.longest, 7, "7 consecutive days should yield longest=7");
|
assert.equal(streak.longest_days, 7, "7 consecutive days should yield longest=7");
|
||||||
assert.ok(streak.current >= 7, "7-day streak eligibility threshold met");
|
assert.ok(streak.current_days >= 7, "7-day streak eligibility threshold met");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 6. Empty days yields zero streak ─────────────────────────────────────────
|
// ── 6. Empty days yields zero streak ─────────────────────────────────────────
|
||||||
{
|
{
|
||||||
const streak = computeStreakFromDays([], TODAY);
|
const streak = computeStreakFromDays([], TODAY);
|
||||||
assert.equal(streak.current, 0);
|
assert.equal(streak.current_days, 0);
|
||||||
assert.equal(streak.longest, 0);
|
assert.equal(streak.longest_days, 0);
|
||||||
assert.equal(streak.lastCompletedDate, null);
|
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 days = ["2026-07-09"];
|
||||||
const streak = computeStreakFromDays(days, TODAY);
|
const streak = computeStreakFromDays(days, TODAY);
|
||||||
assert.equal(streak.current, 0, "current is 0 if today is not completed");
|
assert.equal(streak.current_days, 1, "yesterday remains current during the one-day grace window");
|
||||||
assert.equal(streak.longest, 1, "longest should still count the 1-day run");
|
assert.equal(streak.longest_days, 1, "longest should still count the 1-day run");
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("streak-policy tests passed");
|
console.log("streak-policy tests passed");
|
||||||
|
|||||||
+1
-1
@@ -399,7 +399,7 @@ export const missionServiceSessions = pgTable(
|
|||||||
},
|
},
|
||||||
(t) => ({
|
(t) => ({
|
||||||
userIdx: index("mission_service_sessions_user_idx").on(t.userId, t.updatedAt),
|
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),
|
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 };
|
return { instanceId, missionId, stageId };
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCorrelation(value: unknown) {
|
function normalizeCorrelation(value: unknown, taskAliases: unknown[] = []) {
|
||||||
const record = asRecord(value);
|
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 requestId = getString(record.requestId ?? record.request_id);
|
||||||
const sessionId = getString(record.sessionId ?? record.session_id);
|
const sessionId = getString(record.sessionId ?? record.session_id);
|
||||||
const resumeId = getString(record.resumeId ?? record.resume_id);
|
const resumeId = getString(record.resumeId ?? record.resume_id);
|
||||||
@@ -38,12 +47,19 @@ function normalizeCorrelation(value: unknown) {
|
|||||||
if (sessionId) correlation.sessionId = sessionId;
|
if (sessionId) correlation.sessionId = sessionId;
|
||||||
if (resumeId) correlation.resumeId = resumeId;
|
if (resumeId) correlation.resumeId = resumeId;
|
||||||
if (externalId) correlation.externalId = externalId;
|
if (externalId) correlation.externalId = externalId;
|
||||||
|
if (taskId) correlation.task_id = taskId;
|
||||||
return Object.keys(correlation).length ? correlation : undefined;
|
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> = {
|
const CANONICAL_EVENT_TYPE: Record<string, string> = {
|
||||||
"interview.review.completed": "interview.feedback.generated",
|
"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.session_completed": "roleplay.scenario.completed",
|
||||||
"roleplay.review.completed": "roleplay.feedback.generated",
|
"roleplay.review.completed": "roleplay.feedback.generated",
|
||||||
"course.video.completed": "course.completed",
|
"course.video.completed": "course.completed",
|
||||||
"course.lesson.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.profile.updated": "brand.profile.updated",
|
||||||
"social.post.scheduled": "brand.post.exported",
|
"social.post.scheduled": "brand.post.exported",
|
||||||
"social.post.published": "brand.content.published",
|
"social.post.published": "brand.content.published",
|
||||||
|
"social.content.generated": "brand.post.drafted",
|
||||||
|
"social.account.connected": "brand.account.connected",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function canonicalGrowEventType(type: string): string {
|
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,
|
resume_id: raw.resume_id ?? payload.resume_id,
|
||||||
external_id: raw.external_id ?? payload.external_id,
|
external_id: raw.external_id ?? payload.external_id,
|
||||||
request_id: raw.request_id ?? payload.request_id,
|
request_id: raw.request_id ?? payload.request_id,
|
||||||
});
|
}, normalizeTaskAliases(raw, payload));
|
||||||
const subject = normalizeSubject(raw.subject) ?? (() => {
|
const subject = normalizeSubject(raw.subject) ?? (() => {
|
||||||
const serviceId = getString(raw.subject_service_id ?? payload.subject_service_id);
|
const serviceId = getString(raw.subject_service_id ?? payload.subject_service_id);
|
||||||
const externalId = getString(raw.subject_external_id ?? payload.subject_external_id);
|
const externalId = getString(raw.subject_external_id ?? payload.subject_external_id);
|
||||||
|
|||||||
@@ -71,8 +71,7 @@ export async function applyServiceSessionProjection(event: GrowEventRow) {
|
|||||||
missionInstanceId,
|
missionInstanceId,
|
||||||
missionId,
|
missionId,
|
||||||
stageId,
|
stageId,
|
||||||
taskId: getString(correlation.taskId ?? correlation.curatorTaskId ?? correlation.curator_task_id ?? payload.taskId ?? payload.curatorTaskId ?? payload.curator_task_id),
|
task_id: getString(correlation.task_id ?? payload.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),
|
|
||||||
courseId: getString(correlation.courseId ?? payload.courseId ?? payload.course_id),
|
courseId: getString(correlation.courseId ?? payload.courseId ?? payload.course_id),
|
||||||
lessonId: getString(correlation.lessonId ?? payload.lessonId ?? payload.lesson_id),
|
lessonId: getString(correlation.lessonId ?? payload.lessonId ?? payload.lesson_id),
|
||||||
};
|
};
|
||||||
@@ -102,14 +101,18 @@ export async function applyServiceSessionProjection(event: GrowEventRow) {
|
|||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: [missionServiceSessions.serviceId, missionServiceSessions.externalId],
|
target: [missionServiceSessions.userId, missionServiceSessions.serviceId, missionServiceSessions.externalId],
|
||||||
set: updateValues,
|
set: updateValues,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// Touch the row if Drizzle ever returns no row for an upsert variant.
|
// Touch the row if Drizzle ever returns no row for an upsert variant.
|
||||||
if (!row) {
|
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;
|
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 { db } from "../db/client.js";
|
||||||
import { growEvents, missionServiceSessions, users, type GrowEventRow } from "../db/schema.js";
|
import { growEvents, missionServiceSessions, users, type GrowEventRow } from "../db/schema.js";
|
||||||
import { asRecord, getString, type GrowEventEnvelope } from "./envelope.js";
|
import { asRecord, getString, type GrowEventEnvelope } from "./envelope.js";
|
||||||
@@ -23,14 +23,14 @@ export function correlateEventWithServiceSession(
|
|||||||
): GrowEventEnvelope {
|
): GrowEventEnvelope {
|
||||||
if (!resolvedUserId || !link || link.userId !== resolvedUserId) return event;
|
if (!resolvedUserId || !link || link.userId !== resolvedUserId) return event;
|
||||||
const metadata = asRecord(link.metadata);
|
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 missionInstanceId = getString(link.missionInstanceId ?? metadata.missionInstanceId ?? metadata.mission_instance_id);
|
||||||
const missionId = getString(link.missionId ?? metadata.missionId ?? metadata.mission_id);
|
const missionId = getString(link.missionId ?? metadata.missionId ?? metadata.mission_id);
|
||||||
const stageId = getString(link.stageId ?? metadata.stageId ?? metadata.stage_id);
|
const stageId = getString(link.stageId ?? metadata.stageId ?? metadata.stage_id);
|
||||||
const eventCorrelation = event.correlation ?? {};
|
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 = {
|
const correlation = {
|
||||||
...(taskId && !explicitTaskId ? { taskId, curatorTaskId: taskId } : {}),
|
...(taskId && !explicitTaskId ? { task_id: taskId } : {}),
|
||||||
...(missionInstanceId && !getString(eventCorrelation.missionInstanceId) ? { missionInstanceId } : {}),
|
...(missionInstanceId && !getString(eventCorrelation.missionInstanceId) ? { missionInstanceId } : {}),
|
||||||
...(missionId && !getString(eventCorrelation.missionId) ? { missionId } : {}),
|
...(missionId && !getString(eventCorrelation.missionId) ? { missionId } : {}),
|
||||||
...(stageId && !getString(eventCorrelation.stageId) ? { stageId } : {}),
|
...(stageId && !getString(eventCorrelation.stageId) ? { stageId } : {}),
|
||||||
@@ -77,31 +77,32 @@ export function serviceEventExternalIds(event: GrowEventEnvelope): string[] {
|
|||||||
].map(getString).filter((value): value is string => Boolean(value)))];
|
].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> {
|
async function resolveUserId(event: GrowEventEnvelope): Promise<string | undefined> {
|
||||||
if (event.userId) return event.userId;
|
if (event.userId) return event.userId;
|
||||||
const externalIds = serviceEventExternalIds(event);
|
const externalIds = serviceEventExternalIds(event);
|
||||||
const serviceId = normalizeServiceId(event.source);
|
const serviceId = normalizeServiceId(event.source);
|
||||||
if (!externalIds.length) return undefined;
|
if (!externalIds.length) return undefined;
|
||||||
const [row] = await db
|
const rows = await db
|
||||||
.select({ userId: missionServiceSessions.userId })
|
.select({ userId: missionServiceSessions.userId })
|
||||||
.from(missionServiceSessions)
|
.from(missionServiceSessions)
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(missionServiceSessions.serviceId, serviceId),
|
eq(missionServiceSessions.serviceId, serviceId),
|
||||||
inArrayValues(missionServiceSessions.externalId, externalIds),
|
inArrayValues(missionServiceSessions.externalId, externalIds),
|
||||||
))
|
));
|
||||||
.limit(1);
|
return resolveUniqueSessionUserId(rows);
|
||||||
return row?.userId ?? undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function inArrayValues(column: typeof missionServiceSessions.externalId, values: string[]) {
|
function inArrayValues(column: typeof missionServiceSessions.externalId, values: string[]) {
|
||||||
return or(...values.map((value) => eq(column, value)))!;
|
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> {
|
async function resolveServiceSessionLink(event: GrowEventEnvelope, userId: string | undefined): Promise<ServiceSessionLink | undefined> {
|
||||||
if (!userId) return undefined;
|
if (!userId) return undefined;
|
||||||
const externalIds = serviceEventExternalIds(event);
|
const externalIds = serviceEventExternalIds(event);
|
||||||
@@ -122,39 +123,8 @@ async function resolveServiceSessionLink(event: GrowEventEnvelope, userId: strin
|
|||||||
inArrayValues(missionServiceSessions.externalId, externalIds),
|
inArrayValues(missionServiceSessions.externalId, externalIds),
|
||||||
))
|
))
|
||||||
.limit(1);
|
.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 {
|
export function normalizeServiceId(source: string): string {
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ const serviceRegistry: ServiceRecord[] = [
|
|||||||
abandonmentEvents: ["service.abandoned", "interview.session.abandoned"],
|
abandonmentEvents: ["service.abandoned", "interview.session.abandoned"],
|
||||||
qscoreSignalIds: ["interview.communication_score", "interview.confidence_score", "readiness.practice"],
|
qscoreSignalIds: ["interview.communication_score", "interview.confidence_score", "readiness.practice"],
|
||||||
analyticsDimensions: ["interview_type", "target_role", "difficulty", "duration_minutes", "overall_score"],
|
analyticsDimensions: ["interview_type", "target_role", "difficulty", "duration_minutes", "overall_score"],
|
||||||
requiredCorrelationFields: ["taskId"],
|
requiredCorrelationFields: ["task_id"],
|
||||||
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
||||||
actionKind: "active_user_action",
|
actionKind: "active_user_action",
|
||||||
},
|
},
|
||||||
@@ -301,7 +301,7 @@ const serviceRegistry: ServiceRecord[] = [
|
|||||||
abandonmentEvents: ["service.abandoned", "roleplay.scenario.abandoned"],
|
abandonmentEvents: ["service.abandoned", "roleplay.scenario.abandoned"],
|
||||||
qscoreSignalIds: ["roleplay.negotiation_confidence", "communication.roleplay", "readiness.practice"],
|
qscoreSignalIds: ["roleplay.negotiation_confidence", "communication.roleplay", "readiness.practice"],
|
||||||
analyticsDimensions: ["roleplay_type", "target_role", "difficulty", "duration_minutes", "overall_score"],
|
analyticsDimensions: ["roleplay_type", "target_role", "difficulty", "duration_minutes", "overall_score"],
|
||||||
requiredCorrelationFields: ["taskId"],
|
requiredCorrelationFields: ["task_id"],
|
||||||
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
||||||
actionKind: "active_user_action",
|
actionKind: "active_user_action",
|
||||||
},
|
},
|
||||||
@@ -372,7 +372,7 @@ const serviceRegistry: ServiceRecord[] = [
|
|||||||
tab: "resumes",
|
tab: "resumes",
|
||||||
},
|
},
|
||||||
toolName: "prepare_resume_upload",
|
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"],
|
qscoreSignals: ["proof.resume", "readiness.ats", "profile.skills"],
|
||||||
usage: "Include mission state and optional section when linking into resume work.",
|
usage: "Include mission state and optional section when linking into resume work.",
|
||||||
},
|
},
|
||||||
@@ -385,11 +385,11 @@ const serviceRegistry: ServiceRecord[] = [
|
|||||||
openEvents: ["task.opened"],
|
openEvents: ["task.opened"],
|
||||||
startedEvents: ["service.started", "resume.uploaded"],
|
startedEvents: ["service.started", "resume.uploaded"],
|
||||||
configuredEvents: ["service.configured", "resume.parsed"],
|
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"],
|
abandonmentEvents: ["service.abandoned"],
|
||||||
qscoreSignalIds: ["resume.ats_score", "resume.profile_completeness", "proof.resume"],
|
qscoreSignalIds: ["resume.ats_score", "resume.profile_completeness", "proof.resume"],
|
||||||
analyticsDimensions: ["resume_id", "version_id", "ats_score", "role_fit_score", "updated_sections"],
|
analyticsDimensions: ["resume_id", "version_id", "ats_score", "role_fit_score", "updated_sections"],
|
||||||
requiredCorrelationFields: ["taskId"],
|
requiredCorrelationFields: ["task_id"],
|
||||||
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
||||||
actionKind: "active_user_action",
|
actionKind: "active_user_action",
|
||||||
},
|
},
|
||||||
@@ -470,7 +470,7 @@ const serviceRegistry: ServiceRecord[] = [
|
|||||||
abandonmentEvents: ["service.abandoned"],
|
abandonmentEvents: ["service.abandoned"],
|
||||||
qscoreSignalIds: ["proof.cover_letter", "readiness.application"],
|
qscoreSignalIds: ["proof.cover_letter", "readiness.application"],
|
||||||
analyticsDimensions: ["cover_letter_id", "role_fit_score", "export_format"],
|
analyticsDimensions: ["cover_letter_id", "role_fit_score", "export_format"],
|
||||||
requiredCorrelationFields: ["taskId"],
|
requiredCorrelationFields: ["task_id"],
|
||||||
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
||||||
actionKind: "active_user_action",
|
actionKind: "active_user_action",
|
||||||
},
|
},
|
||||||
@@ -524,7 +524,7 @@ const serviceRegistry: ServiceRecord[] = [
|
|||||||
start: "Start course",
|
start: "Start course",
|
||||||
},
|
},
|
||||||
toolName: "prepare_course_handoff",
|
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"],
|
qscoreSignals: ["courses.started", "courses.completed", "courses.completion_rate"],
|
||||||
usage: "Use for skill-gap tasks that should become learning work.",
|
usage: "Use for skill-gap tasks that should become learning work.",
|
||||||
},
|
},
|
||||||
@@ -534,11 +534,11 @@ const serviceRegistry: ServiceRecord[] = [
|
|||||||
openEvents: ["task.opened"],
|
openEvents: ["task.opened"],
|
||||||
startedEvents: ["service.started", "course.started"],
|
startedEvents: ["service.started", "course.started"],
|
||||||
configuredEvents: ["course.generated"],
|
configuredEvents: ["course.generated"],
|
||||||
completionEvents: ["service.completed", "course.completed"],
|
completionEvents: ["service.completed", "course.generated", "course.started", "course.progress.recorded", "course.completed"],
|
||||||
abandonmentEvents: ["service.abandoned"],
|
abandonmentEvents: ["service.abandoned"],
|
||||||
qscoreSignalIds: ["courses.started", "courses.completed", "courses.completion_rate", "courses.difficulty", "courses.pathway_relevance"],
|
qscoreSignalIds: ["courses.started", "courses.completed", "courses.completion_rate", "courses.difficulty", "courses.pathway_relevance"],
|
||||||
analyticsDimensions: ["course_id", "lesson_id", "progress_percentage", "weakness_tags"],
|
analyticsDimensions: ["course_id", "lesson_id", "progress_percentage", "weakness_tags"],
|
||||||
requiredCorrelationFields: ["taskId"],
|
requiredCorrelationFields: ["task_id"],
|
||||||
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
||||||
actionKind: "active_user_action",
|
actionKind: "active_user_action",
|
||||||
},
|
},
|
||||||
@@ -597,7 +597,7 @@ const serviceRegistry: ServiceRecord[] = [
|
|||||||
abandonmentEvents: ["service.abandoned"],
|
abandonmentEvents: ["service.abandoned"],
|
||||||
qscoreSignalIds: ["skills.assessment", "readiness.measurement"],
|
qscoreSignalIds: ["skills.assessment", "readiness.measurement"],
|
||||||
analyticsDimensions: ["assessment_id", "skill_area", "score", "weakness_tags"],
|
analyticsDimensions: ["assessment_id", "skill_area", "score", "weakness_tags"],
|
||||||
requiredCorrelationFields: ["taskId"],
|
requiredCorrelationFields: ["task_id"],
|
||||||
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
||||||
actionKind: "measurement_review",
|
actionKind: "measurement_review",
|
||||||
},
|
},
|
||||||
@@ -646,6 +646,9 @@ const serviceRegistry: ServiceRecord[] = [
|
|||||||
},
|
},
|
||||||
toolName: "prepare_matchmaking_handoff",
|
toolName: "prepare_matchmaking_handoff",
|
||||||
completionEvents: [
|
completionEvents: [
|
||||||
|
"matchmaking.feed.viewed",
|
||||||
|
"matchmaking.match.viewed",
|
||||||
|
"matchmaking.application.started",
|
||||||
"matchmaking.match.saved",
|
"matchmaking.match.saved",
|
||||||
"matchmaking.matches.reviewed",
|
"matchmaking.matches.reviewed",
|
||||||
"matchmaking.match.applied",
|
"matchmaking.match.applied",
|
||||||
@@ -665,6 +668,9 @@ const serviceRegistry: ServiceRecord[] = [
|
|||||||
configuredEvents: [],
|
configuredEvents: [],
|
||||||
completionEvents: [
|
completionEvents: [
|
||||||
"service.completed",
|
"service.completed",
|
||||||
|
"matchmaking.feed.viewed",
|
||||||
|
"matchmaking.match.viewed",
|
||||||
|
"matchmaking.application.started",
|
||||||
"matchmaking.match.saved",
|
"matchmaking.match.saved",
|
||||||
"matchmaking.matches.reviewed",
|
"matchmaking.matches.reviewed",
|
||||||
"matchmaking.match.applied",
|
"matchmaking.match.applied",
|
||||||
@@ -673,7 +679,7 @@ const serviceRegistry: ServiceRecord[] = [
|
|||||||
abandonmentEvents: ["service.abandoned"],
|
abandonmentEvents: ["service.abandoned"],
|
||||||
qscoreSignalIds: ["matching.jobs_viewed", "matching.applications_submitted", "matching.application_quality", "matching.match_rate"],
|
qscoreSignalIds: ["matching.jobs_viewed", "matching.applications_submitted", "matching.application_quality", "matching.match_rate"],
|
||||||
analyticsDimensions: ["match_count", "role_ids", "role_fit", "saved_count", "dismissed_reason"],
|
analyticsDimensions: ["match_count", "role_ids", "role_fit", "saved_count", "dismissed_reason"],
|
||||||
requiredCorrelationFields: ["taskId"],
|
requiredCorrelationFields: ["task_id"],
|
||||||
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
||||||
actionKind: "active_user_action",
|
actionKind: "active_user_action",
|
||||||
},
|
},
|
||||||
@@ -717,7 +723,7 @@ const serviceRegistry: ServiceRecord[] = [
|
|||||||
review: "Review QScore",
|
review: "Review QScore",
|
||||||
},
|
},
|
||||||
toolName: "prepare_qscore_review",
|
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"],
|
qscoreSignals: ["qscore.updated", "qscore.signal.projected"],
|
||||||
usage: "Use for measurement and projected readiness review tasks.",
|
usage: "Use for measurement and projected readiness review tasks.",
|
||||||
},
|
},
|
||||||
@@ -727,7 +733,7 @@ const serviceRegistry: ServiceRecord[] = [
|
|||||||
openEvents: ["qscore.review.opened"],
|
openEvents: ["qscore.review.opened"],
|
||||||
startedEvents: ["service.started"],
|
startedEvents: ["service.started"],
|
||||||
configuredEvents: ["qscore.baseline.created"],
|
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: [],
|
abandonmentEvents: [],
|
||||||
qscoreSignalIds: ["qscore.updated", "qscore.signal.projected"],
|
qscoreSignalIds: ["qscore.updated", "qscore.signal.projected"],
|
||||||
analyticsDimensions: ["score_before", "score_after", "signal_id", "delta", "trend_label"],
|
analyticsDimensions: ["score_before", "score_after", "signal_id", "delta", "trend_label"],
|
||||||
@@ -777,7 +783,7 @@ const serviceRegistry: ServiceRecord[] = [
|
|||||||
review: "Review profile",
|
review: "Review profile",
|
||||||
},
|
},
|
||||||
toolName: "prepare_social_branding_handoff",
|
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"],
|
qscoreSignals: ["profile.linkedin", "proof.visibility", "networking.brand"],
|
||||||
usage: "Use for profile visibility, LinkedIn cleanup, and social proof tasks.",
|
usage: "Use for profile visibility, LinkedIn cleanup, and social proof tasks.",
|
||||||
},
|
},
|
||||||
@@ -787,11 +793,11 @@ const serviceRegistry: ServiceRecord[] = [
|
|||||||
openEvents: ["task.opened"],
|
openEvents: ["task.opened"],
|
||||||
startedEvents: ["service.started"],
|
startedEvents: ["service.started"],
|
||||||
configuredEvents: ["brand.post.drafted"],
|
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"],
|
abandonmentEvents: ["service.abandoned"],
|
||||||
qscoreSignalIds: ["brand.visibility_signal", "profile.linkedin", "proof.visibility"],
|
qscoreSignalIds: ["brand.visibility_signal", "profile.linkedin", "proof.visibility"],
|
||||||
analyticsDimensions: ["content_type", "channel", "draft_id", "topic", "profile_field_updated"],
|
analyticsDimensions: ["content_type", "channel", "draft_id", "topic", "profile_field_updated"],
|
||||||
requiredCorrelationFields: ["taskId"],
|
requiredCorrelationFields: ["task_id"],
|
||||||
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
||||||
actionKind: "active_user_action",
|
actionKind: "active_user_action",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ function taskActionDate(input: { taskId: string; date?: string }) {
|
|||||||
return input.date ?? dateFromTaskId(input.taskId) ?? todayIsoDate();
|
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 }) {
|
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");
|
if (c.state.userId && c.state.userId !== input.userId) throw new Error("curatorActor initialized for a different user");
|
||||||
c.state.userId = input.userId;
|
c.state.userId = input.userId;
|
||||||
@@ -102,18 +109,8 @@ export const curatorService = {
|
|||||||
const date = taskActionDate(input);
|
const date = taskActionDate(input);
|
||||||
const task = (await buildCuratorTasks(input.userId, date)).find((item) => item.id === input.taskId);
|
const task = (await buildCuratorTasks(input.userId, date)).find((item) => item.id === input.taskId);
|
||||||
const reason = input.reason ?? "subtasks_completed";
|
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");
|
if (!task) throw new Error("curator_task_not_found");
|
||||||
const allowDirectServiceCompletion =
|
const allowDirectServiceCompletion = allowsDirectServiceCompletion(task.serviceId, reason);
|
||||||
(task.serviceId === "qscore-service" && reason === "qscore_review_opened") ||
|
|
||||||
(task.serviceId === "matchmaking-service" && reason === "matchmaking_matches_reviewed");
|
|
||||||
if (task.serviceId && !allowDirectServiceCompletion) {
|
if (task.serviceId && !allowDirectServiceCompletion) {
|
||||||
throw new Error("curator_service_task_requires_service_event");
|
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 };
|
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[] }) {
|
async applyImprovementSignals(input: { userId: string; date: string; signals: CuratorImprovementSignal[] }) {
|
||||||
for (const signal of input.signals) {
|
for (const signal of input.signals) {
|
||||||
@@ -185,11 +177,6 @@ export const curatorActor = actor({
|
|||||||
c.state.lastEventId = result.eventId;
|
c.state.lastEventId = result.eventId;
|
||||||
return result;
|
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]) => {
|
applyImprovementSignals: async (c, input: Parameters<typeof curatorService.applyImprovementSignals>[0]) => {
|
||||||
touch(c, input);
|
touch(c, input);
|
||||||
return curatorService.applyImprovementSignals(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 }));
|
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) => {
|
app.get("/state", async (c) => {
|
||||||
const userId = c.get("userId");
|
const userId = c.get("userId");
|
||||||
|
|||||||
@@ -34,10 +34,14 @@ import {
|
|||||||
} from "./task-registry.js";
|
} from "./task-registry.js";
|
||||||
import { kpiDefinitionsForIcp } from "./kpi-registry.js";
|
import { kpiDefinitionsForIcp } from "./kpi-registry.js";
|
||||||
import { outcomeDefinitionsForIcp } from "./outcome-report-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 = [
|
const VALID_COMPLETION_TYPES = [
|
||||||
"service.completed",
|
"service.completed",
|
||||||
|
"resume.uploaded",
|
||||||
|
"resume.parsed",
|
||||||
"resume.analysis.completed",
|
"resume.analysis.completed",
|
||||||
"resume.updated",
|
"resume.updated",
|
||||||
"resume.exported",
|
"resume.exported",
|
||||||
@@ -46,23 +50,33 @@ const VALID_COMPLETION_TYPES = [
|
|||||||
"roleplay.scenario.completed",
|
"roleplay.scenario.completed",
|
||||||
"roleplay.feedback.generated",
|
"roleplay.feedback.generated",
|
||||||
"assessment.completed",
|
"assessment.completed",
|
||||||
|
"course.generated",
|
||||||
|
"course.started",
|
||||||
|
"course.progress.recorded",
|
||||||
"course.completed",
|
"course.completed",
|
||||||
|
"brand.account.connected",
|
||||||
|
"brand.post.drafted",
|
||||||
"brand.post.exported",
|
"brand.post.exported",
|
||||||
"brand.profile.updated",
|
"brand.profile.updated",
|
||||||
"brand.content.published",
|
"brand.content.published",
|
||||||
"social.profile.updated",
|
"matchmaking.feed.viewed",
|
||||||
"social.post.scheduled",
|
"matchmaking.match.viewed",
|
||||||
"social.post.published",
|
"matchmaking.application.started",
|
||||||
"matchmaking.match.saved",
|
"matchmaking.match.saved",
|
||||||
"matchmaking.matches.reviewed",
|
"matchmaking.matches.reviewed",
|
||||||
"matchmaking.match.applied",
|
"matchmaking.match.applied",
|
||||||
"matchmaking.application.completed",
|
"matchmaking.application.completed",
|
||||||
|
"qscore.review.opened",
|
||||||
"qscore.weak_driver.reviewed",
|
"qscore.weak_driver.reviewed",
|
||||||
"qscore.updated",
|
"qscore.updated",
|
||||||
"qscore.signal.projected",
|
"qscore.signal.projected",
|
||||||
"curator.task.completed",
|
"curator.task.completed",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export function isStreakCompletionType(type: string) {
|
||||||
|
return (VALID_COMPLETION_TYPES as readonly string[]).includes(type);
|
||||||
|
}
|
||||||
|
|
||||||
const CURATOR_SOURCE = "curator-v1";
|
const CURATOR_SOURCE = "curator-v1";
|
||||||
const CURATOR_PLAN_VERSION = "icp-v10-static";
|
const CURATOR_PLAN_VERSION = "icp-v10-static";
|
||||||
const FULL_SPRINT_DURATION_DAYS = 7;
|
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));
|
return Math.max(min, Math.min(max, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function sprintIdFor(startDate: string) {
|
function sprintIdFor(startDate: string) {
|
||||||
return `curator-sprint:${CURATOR_PLAN_VERSION}:${startDate}`;
|
return `curator-sprint:${CURATOR_PLAN_VERSION}:${startDate}`;
|
||||||
}
|
}
|
||||||
@@ -693,7 +708,7 @@ function clonePlanDay(day: PlanDaySeed): PlanDaySeed {
|
|||||||
|
|
||||||
async function loadSprintState(
|
async function loadSprintState(
|
||||||
userId: string,
|
userId: string,
|
||||||
todayDate = todayIso(),
|
todayDate = curatorLogicalDate(),
|
||||||
onboardingContextOverride?: Record<string, unknown>,
|
onboardingContextOverride?: Record<string, unknown>,
|
||||||
): Promise<SprintState> {
|
): Promise<SprintState> {
|
||||||
const savedOnboarding = await db.query.onboarding.findFirst({
|
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 existingAccess = latestPayload?.access === "full" ? "full" : latestPayload?.access === "trial" ? "trial" : undefined;
|
||||||
const existingFingerprint = typeof latestPayload?.planFingerprint === "string" ? latestPayload.planFingerprint : undefined;
|
const existingFingerprint = typeof latestPayload?.planFingerprint === "string" ? latestPayload.planFingerprint : undefined;
|
||||||
const rebaseExistingForCompletion = Boolean(onboardingContextOverride && existingStartDate && existingStartDate !== todayDate);
|
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(
|
const activeExisting = Boolean(
|
||||||
!rebaseExistingForCompletion && existingStartDate && existingVersion === CURATOR_PLAN_VERSION && existingVariant
|
!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;
|
let replacementStartDate = todayDate;
|
||||||
@@ -861,15 +884,7 @@ async function loadCurrentQScore(userId: string) {
|
|||||||
export type CuratorCompletionEvent = Pick<GrowEventRow, "type" | "payload" | "correlation">;
|
export type CuratorCompletionEvent = Pick<GrowEventRow, "type" | "payload" | "correlation">;
|
||||||
|
|
||||||
export function isTaskCompletionEvent(taskId: string, completionEvents: string[], row: CuratorCompletionEvent) {
|
export function isTaskCompletionEvent(taskId: string, completionEvents: string[], row: CuratorCompletionEvent) {
|
||||||
const payload = row.payload ?? {};
|
const eventTaskId = row.correlation?.task_id;
|
||||||
const correlation = row.correlation ?? {};
|
|
||||||
const eventTaskId =
|
|
||||||
payload.taskId ??
|
|
||||||
payload.curatorTaskId ??
|
|
||||||
payload.curator_task_id ??
|
|
||||||
correlation.taskId ??
|
|
||||||
correlation.curatorTaskId ??
|
|
||||||
correlation.curator_task_id;
|
|
||||||
return eventTaskId === taskId && (
|
return eventTaskId === taskId && (
|
||||||
row.type === "curator.task.completed" ||
|
row.type === "curator.task.completed" ||
|
||||||
row.type === "service.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()));
|
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">) {
|
function taskEventId(row: Pick<Awaited<ReturnType<typeof loadRecentContextRows>>[number], "correlation">) {
|
||||||
const payload = row.payload ?? {};
|
const value = row.correlation?.task_id;
|
||||||
const correlation = row.correlation ?? {};
|
|
||||||
const value =
|
|
||||||
payload.taskId ??
|
|
||||||
payload.curatorTaskId ??
|
|
||||||
payload.curator_task_id ??
|
|
||||||
correlation.taskId ??
|
|
||||||
correlation.curatorTaskId ??
|
|
||||||
correlation.curator_task_id;
|
|
||||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -995,10 +1002,7 @@ function trialRecoveryPlanDay(
|
|||||||
...current,
|
...current,
|
||||||
focus: recovery.focus,
|
focus: recovery.focus,
|
||||||
outcome: recovery.outcome,
|
outcome: recovery.outcome,
|
||||||
plannedTasks: [
|
plannedTasks: composeTrialDay2(variantId, 0, current.plannedTasks.filter((task) => !task.isRecoveryPractice), recoveryTask),
|
||||||
recoveryTask,
|
|
||||||
...current.plannedTasks.filter((task) => !task.isRecoveryPractice).slice(0, 3),
|
|
||||||
],
|
|
||||||
generationStatus: "adapted",
|
generationStatus: "adapted",
|
||||||
adaptationReason: `trial day ${missedDayIndex} missed; assigned ${recovery.serviceId} practice recovery`,
|
adaptationReason: `trial day ${missedDayIndex} missed; assigned ${recovery.serviceId} practice recovery`,
|
||||||
hasRecoveryPractice: true,
|
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 sprintState = await loadSprintState(userId, date);
|
||||||
const sprintStartDate = sprintState.startDate;
|
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 completionRows = await loadCompletionRows(userId, sprintStartDate);
|
||||||
const recentRows = await loadRecentContextRows(userId, sprintStartDate);
|
const recentRows = await loadRecentContextRows(userId, sprintStartDate);
|
||||||
const currentQScore = await loadCurrentQScore(userId);
|
const currentQScore = await loadCurrentQScore(userId);
|
||||||
@@ -1291,7 +1295,7 @@ export async function buildCuratorStreak(userId: string): Promise<CuratorStreak>
|
|||||||
.where(and(
|
.where(and(
|
||||||
eq(growEvents.userId, userId),
|
eq(growEvents.userId, userId),
|
||||||
inArray(growEvents.type, [...VALID_COMPLETION_TYPES]),
|
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')`)
|
.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`)
|
.orderBy(sql`to_char(${growEvents.occurredAt} AT TIME ZONE 'Asia/Kolkata', 'YYYY-MM-DD') desc`)
|
||||||
@@ -1322,7 +1326,7 @@ type CuratorSprintBuildOptions = {
|
|||||||
|
|
||||||
async function buildCuratorSprintInternal(
|
async function buildCuratorSprintInternal(
|
||||||
userId: string,
|
userId: string,
|
||||||
focusDate = todayIso(),
|
focusDate = curatorLogicalDate(),
|
||||||
options: CuratorSprintBuildOptions = {},
|
options: CuratorSprintBuildOptions = {},
|
||||||
): Promise<CuratorSprintResponse> {
|
): Promise<CuratorSprintResponse> {
|
||||||
const sprintState = await loadSprintState(userId, focusDate, options.onboardingContext);
|
const sprintState = await loadSprintState(userId, focusDate, options.onboardingContext);
|
||||||
@@ -1333,7 +1337,7 @@ async function buildCuratorSprintInternal(
|
|||||||
const recentRows = await loadRecentContextRows(userId, sprintStartDate);
|
const recentRows = await loadRecentContextRows(userId, sprintStartDate);
|
||||||
const currentQScore = await loadCurrentQScore(userId);
|
const currentQScore = await loadCurrentQScore(userId);
|
||||||
const userContext = await buildCuratorUserContext(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(
|
const planDays = adaptCurrentDayPlan(
|
||||||
sprintStartDate,
|
sprintStartDate,
|
||||||
sprintState.variantId,
|
sprintState.variantId,
|
||||||
@@ -1461,7 +1465,7 @@ export async function buildCuratorPlan(userId: string, input: { startDate: strin
|
|||||||
|
|
||||||
export async function buildCuratorSprint(
|
export async function buildCuratorSprint(
|
||||||
userId: string,
|
userId: string,
|
||||||
date = todayIso(),
|
date = curatorLogicalDate(),
|
||||||
options: CuratorSprintBuildOptions = {},
|
options: CuratorSprintBuildOptions = {},
|
||||||
): Promise<CuratorSprintResponse> {
|
): Promise<CuratorSprintResponse> {
|
||||||
return buildCuratorSprintInternal(userId, date, options);
|
return buildCuratorSprintInternal(userId, date, options);
|
||||||
@@ -1576,7 +1580,7 @@ async function saveSprintReport(report: CuratorSprintReport) {
|
|||||||
|
|
||||||
export async function buildCuratorSprintReport(
|
export async function buildCuratorSprintReport(
|
||||||
userId: string,
|
userId: string,
|
||||||
date = todayIso(),
|
date = curatorLogicalDate(),
|
||||||
options: CuratorSprintReportBuildOptions = {},
|
options: CuratorSprintReportBuildOptions = {},
|
||||||
): Promise<CuratorSprintReport> {
|
): Promise<CuratorSprintReport> {
|
||||||
const sprintState = await loadSprintState(userId, date);
|
const sprintState = await loadSprintState(userId, date);
|
||||||
@@ -1619,7 +1623,7 @@ export async function buildCuratorSprintReport(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function buildServiceCurationPreview(input: ServiceCurationPreviewInput) {
|
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 inferredIcpId = input.icpId ?? await inferIcpVariant(input.userId);
|
||||||
const templateSet = templateSetFor(inferredIcpId);
|
const templateSet = templateSetFor(inferredIcpId);
|
||||||
const userContext = {
|
const userContext = {
|
||||||
@@ -1675,5 +1679,5 @@ export async function listCuratorRegistryCapabilities() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function todayIsoDate() {
|
export function todayIsoDate() {
|
||||||
return todayIso();
|
return curatorLogicalDate();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,56 @@ export function indiaTodayIso(date = new Date()): string {
|
|||||||
}).format(date);
|
}).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 {
|
export function addDaysIso(startDate: string, days: number): string {
|
||||||
const date = new Date(`${startDate}T00:00:00.000Z`);
|
const date = new Date(`${startDate}T00:00:00.000Z`);
|
||||||
date.setUTCDate(date.getUTCDate() + days);
|
date.setUTCDate(date.getUTCDate() + days);
|
||||||
return todayIso(date);
|
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
|
* Pure streak computation from a descending-sorted list of completion day
|
||||||
* strings (YYYY-MM-DD). Extracted so streak policies (current/longest,
|
* strings (YYYY-MM-DD). Extracted so streak policies (current/longest,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const eventTrackSchema = z.object({
|
|||||||
dedupeKey: z.string().optional(),
|
dedupeKey: z.string().optional(),
|
||||||
dedupe_key: z.string().optional(),
|
dedupe_key: z.string().optional(),
|
||||||
taskId: z.string().optional(),
|
taskId: z.string().optional(),
|
||||||
|
task_id: z.string().optional(),
|
||||||
curatorTaskId: z.string().optional(),
|
curatorTaskId: z.string().optional(),
|
||||||
curator_task_id: z.string().optional(),
|
curator_task_id: z.string().optional(),
|
||||||
serviceId: z.string().optional(),
|
serviceId: z.string().optional(),
|
||||||
@@ -46,20 +47,20 @@ export function v1EventRoutes() {
|
|||||||
const body = eventTrackSchema.parse(await c.req.json());
|
const body = eventTrackSchema.parse(await c.req.json());
|
||||||
const type = body.type ?? body.action;
|
const type = body.type ?? body.action;
|
||||||
if (!type) return c.json({ error: "event_type_required" }, 400);
|
if (!type) return c.json({ error: "event_type_required" }, 400);
|
||||||
|
const nestedCorrelation = body.correlation ?? {};
|
||||||
const payload = {
|
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.payload ?? {}),
|
||||||
...(body.metadata ? { metadata: body.metadata } : {}),
|
...(body.metadata ? { metadata: body.metadata } : {}),
|
||||||
taskId: body.taskId,
|
...(taskId ? { task_id: taskId } : {}),
|
||||||
curatorTaskId: body.curatorTaskId ?? body.curator_task_id ?? body.taskId,
|
...(body.serviceId ?? body.service_id ? { serviceId: body.serviceId ?? body.service_id } : {}),
|
||||||
serviceId: body.serviceId ?? body.service_id,
|
|
||||||
status: (body.payload?.status ?? body.metadata?.status) as unknown,
|
status: (body.payload?.status ?? body.metadata?.status) as unknown,
|
||||||
};
|
});
|
||||||
const correlation = compactRecord({
|
const correlation = compactRecord({
|
||||||
...(body.correlation ?? {}),
|
...nestedCorrelation,
|
||||||
taskId: body.taskId,
|
...(!nestedTaskId && taskId ? { task_id: taskId } : {}),
|
||||||
curatorTaskId: body.curatorTaskId ?? body.curator_task_id ?? body.taskId,
|
...(body.serviceId ?? body.service_id ? { serviceId: body.serviceId ?? body.service_id } : {}),
|
||||||
serviceId: body.serviceId ?? body.service_id,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { event, inserted } = await recordGrowEventWithResult({
|
const { event, inserted } = await recordGrowEventWithResult({
|
||||||
|
|||||||
Reference in New Issue
Block a user