fix(curator): enforce correlated task completion
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
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 { templateSetFor } from "../src/v1/curator/task-registry.js";
|
||||
import type { CuratorIcpId } from "../src/v1/curator/icp-registry.js";
|
||||
|
||||
const taskId = "curator-task-1";
|
||||
const event = (type: string, correlatedTaskId = taskId): CuratorCompletionEvent => ({
|
||||
type,
|
||||
payload: {},
|
||||
correlation: { taskId: correlatedTaskId, curatorTaskId: correlatedTaskId },
|
||||
});
|
||||
|
||||
const roleplayEvents = getCompletionEvents("roleplay-service");
|
||||
assert.deepEqual(roleplayEvents, ["roleplay.scenario.completed", "roleplay.feedback.generated"]);
|
||||
assert.equal(isTaskCompletionEvent(taskId, roleplayEvents, event("roleplay.scenario.configured")), false);
|
||||
assert.equal(isTaskCompletionEvent(taskId, roleplayEvents, event("roleplay.scenario.completed")), true);
|
||||
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 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);
|
||||
|
||||
const resumeEvents = getCompletionEvents("resume-service");
|
||||
assert.equal(isTaskCompletionEvent(taskId, resumeEvents, event("resume.parsed")), false);
|
||||
assert.equal(isTaskCompletionEvent(taskId, resumeEvents, event("resume.analysis.completed")), true);
|
||||
|
||||
const assessmentEvents = getCompletionEvents("assessment-service");
|
||||
assert.equal(isTaskCompletionEvent(taskId, assessmentEvents, event("assessment.started")), false);
|
||||
assert.equal(isTaskCompletionEvent(taskId, assessmentEvents, event("assessment.completed")), true);
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
const icpIds: CuratorIcpId[] = [
|
||||
"student_recent_grad",
|
||||
"intern",
|
||||
"fresher_early_professional",
|
||||
"experienced_professional",
|
||||
"freelancer",
|
||||
"founder",
|
||||
"enterprise",
|
||||
];
|
||||
|
||||
for (const icpId of icpIds) {
|
||||
const trialDays = planSeedsForVariant(templateSetFor(icpId), "2026-07-14", 2, "trial");
|
||||
assert.equal(trialDays.length, 2, `${icpId} must expose exactly two trial days`);
|
||||
for (const day of trialDays) {
|
||||
assert.equal(day.plannedTasks.length, 4, `${icpId} day ${day.dayIndex} must expose four tasks`);
|
||||
for (const [index, task] of day.plannedTasks.entries()) {
|
||||
const completionEvents = getCompletionEvents(task.serviceId);
|
||||
const syntheticTaskId = `${icpId}:day-${day.dayIndex}:task-${index + 1}`;
|
||||
assert.ok(completionEvents.length > 0, `${syntheticTaskId} must have completion evidence`);
|
||||
assert.equal(
|
||||
isTaskCompletionEvent(syntheticTaskId, completionEvents, event(completionEvents[0]!, syntheticTaskId)),
|
||||
true,
|
||||
`${syntheticTaskId} must complete from its exact correlated service event`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const serviceId of [
|
||||
"resume-service",
|
||||
"interview-service",
|
||||
"roleplay-service",
|
||||
"courses-service",
|
||||
"social-branding-service",
|
||||
"matchmaking-service",
|
||||
"qscore-service",
|
||||
]) {
|
||||
assert.ok(getCompletionEvents(serviceId).length > 0, `${serviceId} must define completion evidence`);
|
||||
}
|
||||
|
||||
console.log("curator service completion contract tests passed");
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { correlateEventWithServiceSession } from "../src/events/record-grow-event.js";
|
||||
import { correlateEventWithServiceSession, normalizeServiceId, serviceEventExternalIds } from "../src/events/record-grow-event.js";
|
||||
import { canonicalGrowEventType } from "../src/events/normalize.js";
|
||||
import type { GrowEventEnvelope } from "../src/events/envelope.js";
|
||||
|
||||
const event: GrowEventEnvelope = {
|
||||
@@ -58,4 +59,28 @@ const link = {
|
||||
assert.deepEqual(explicit.mission, { missionId: "explicit-mission", stageId: "explicit-stage" });
|
||||
}
|
||||
|
||||
{
|
||||
assert.deepEqual(serviceEventExternalIds({
|
||||
...event,
|
||||
correlation: { courseId: "course-1", lesson_id: "lesson-1", externalId: "course-1" },
|
||||
subject: { serviceId: "courses-service", externalId: "lesson-1" },
|
||||
payload: { resume_id: "resume-1", assessmentId: "assessment-1" },
|
||||
}), ["course-1", "lesson-1", "resume-1", "assessment-1"]);
|
||||
}
|
||||
|
||||
{
|
||||
assert.equal(normalizeServiceId("social-branding-service"), "brand");
|
||||
assert.equal(normalizeServiceId("courses-service"), "course");
|
||||
assert.equal(normalizeServiceId("cover-letter-service"), "cover_letter");
|
||||
assert.equal(normalizeServiceId("matchmaking-v2"), "matchmaking");
|
||||
}
|
||||
|
||||
{
|
||||
assert.equal(canonicalGrowEventType("roleplay.session.completed"), "roleplay.scenario.completed");
|
||||
assert.equal(canonicalGrowEventType("roleplay.review.completed"), "roleplay.feedback.generated");
|
||||
assert.equal(canonicalGrowEventType("course.video.completed"), "course.completed");
|
||||
assert.equal(canonicalGrowEventType("social.profile.synced"), "brand.profile.updated");
|
||||
assert.equal(canonicalGrowEventType("resume.analysis.completed"), "resume.analysis.completed");
|
||||
}
|
||||
|
||||
console.log("service session correlation tests passed");
|
||||
|
||||
+18
-1
@@ -41,6 +41,23 @@ function normalizeCorrelation(value: unknown) {
|
||||
return Object.keys(correlation).length ? correlation : undefined;
|
||||
}
|
||||
|
||||
const CANONICAL_EVENT_TYPE: Record<string, string> = {
|
||||
"interview.review.completed": "interview.feedback.generated",
|
||||
"roleplay.session.completed": "roleplay.scenario.completed",
|
||||
"roleplay.review.completed": "roleplay.feedback.generated",
|
||||
"course.video.completed": "course.completed",
|
||||
"course.lesson.completed": "course.completed",
|
||||
"course.progress_recorded": "course.progress.recorded",
|
||||
"social.profile.synced": "brand.profile.updated",
|
||||
"social.profile.updated": "brand.profile.updated",
|
||||
"social.post.scheduled": "brand.post.exported",
|
||||
"social.post.published": "brand.content.published",
|
||||
};
|
||||
|
||||
export function canonicalGrowEventType(type: string): string {
|
||||
return CANONICAL_EVENT_TYPE[type] ?? type;
|
||||
}
|
||||
|
||||
export function normalizeGrowEvent(input: unknown, overrides: { userId?: string; source?: string } = {}): GrowEventEnvelope {
|
||||
const raw = asRecord(input);
|
||||
const payload = asRecord(raw.payload ?? raw.data ?? raw.event_payload);
|
||||
@@ -60,7 +77,7 @@ export function normalizeGrowEvent(input: unknown, overrides: { userId?: string;
|
||||
})();
|
||||
|
||||
const source = overrides.source ?? getString(raw.source) ?? "unknown-service";
|
||||
const type = getString(raw.type ?? raw.event_type ?? raw.action) ?? "service.event";
|
||||
const type = canonicalGrowEventType(getString(raw.type ?? raw.event_type ?? raw.action) ?? "service.event");
|
||||
const categoryRaw = raw.category ?? payload.category;
|
||||
const category = isGrowEventCategory(categoryRaw) ? categoryRaw : "service";
|
||||
const userId = overrides.userId ?? getString(raw.userId ?? raw.user_id ?? payload.userId ?? payload.user_id);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, desc, eq, sql } from "drizzle-orm";
|
||||
import { and, desc, eq, or, sql } 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";
|
||||
@@ -44,25 +44,68 @@ export function correlateEventWithServiceSession(
|
||||
return { ...event, correlation, mission };
|
||||
}
|
||||
|
||||
export function serviceEventExternalIds(event: GrowEventEnvelope): string[] {
|
||||
const correlation = event.correlation ?? {};
|
||||
const payload = event.payload ?? {};
|
||||
const subject = event.subject ?? {};
|
||||
return [...new Set([
|
||||
correlation.sessionId,
|
||||
correlation.session_id,
|
||||
correlation.externalId,
|
||||
correlation.external_id,
|
||||
correlation.resumeId,
|
||||
correlation.resume_id,
|
||||
correlation.courseId,
|
||||
correlation.course_id,
|
||||
correlation.lessonId,
|
||||
correlation.lesson_id,
|
||||
correlation.assessmentId,
|
||||
correlation.assessment_id,
|
||||
subject.externalId,
|
||||
payload.sessionId,
|
||||
payload.session_id,
|
||||
payload.externalId,
|
||||
payload.external_id,
|
||||
payload.resumeId,
|
||||
payload.resume_id,
|
||||
payload.courseId,
|
||||
payload.course_id,
|
||||
payload.lessonId,
|
||||
payload.lesson_id,
|
||||
payload.assessmentId,
|
||||
payload.assessment_id,
|
||||
].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 sessionId = getString(event.correlation?.sessionId ?? event.correlation?.session_id);
|
||||
const externalIds = serviceEventExternalIds(event);
|
||||
const serviceId = normalizeServiceId(event.source);
|
||||
if (sessionId) {
|
||||
if (!externalIds.length) return undefined;
|
||||
const [row] = await db
|
||||
.select({ userId: missionServiceSessions.userId })
|
||||
.from(missionServiceSessions)
|
||||
.where(and(eq(missionServiceSessions.serviceId, serviceId), eq(missionServiceSessions.externalId, sessionId)))
|
||||
.where(and(
|
||||
eq(missionServiceSessions.serviceId, serviceId),
|
||||
inArrayValues(missionServiceSessions.externalId, externalIds),
|
||||
))
|
||||
.limit(1);
|
||||
if (row?.userId) return row.userId;
|
||||
return row?.userId ?? undefined;
|
||||
}
|
||||
return undefined;
|
||||
|
||||
function inArrayValues(column: typeof missionServiceSessions.externalId, values: string[]) {
|
||||
return or(...values.map((value) => eq(column, value)))!;
|
||||
}
|
||||
|
||||
async function resolveServiceSessionLink(event: GrowEventEnvelope, userId: string | undefined): Promise<ServiceSessionLink | undefined> {
|
||||
if (!userId) return undefined;
|
||||
const sessionId = getString(event.correlation?.sessionId ?? event.correlation?.session_id);
|
||||
if (!sessionId) return undefined;
|
||||
const externalIds = serviceEventExternalIds(event);
|
||||
if (!externalIds.length) return undefined;
|
||||
const serviceId = normalizeServiceId(event.source);
|
||||
const [row] = await db
|
||||
.select({
|
||||
@@ -76,7 +119,7 @@ async function resolveServiceSessionLink(event: GrowEventEnvelope, userId: strin
|
||||
.where(and(
|
||||
eq(missionServiceSessions.userId, userId),
|
||||
eq(missionServiceSessions.serviceId, serviceId),
|
||||
eq(missionServiceSessions.externalId, sessionId),
|
||||
inArrayValues(missionServiceSessions.externalId, externalIds),
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
@@ -84,16 +127,19 @@ async function resolveServiceSessionLink(event: GrowEventEnvelope, userId: strin
|
||||
const rowTaskId = getString(rowMetadata.taskId ?? rowMetadata.curatorTaskId ?? rowMetadata.curator_task_id);
|
||||
if (rowTaskId) return row;
|
||||
|
||||
const [prior] = await db
|
||||
.select({ correlation: growEvents.correlation, payload: growEvents.payload, mission: growEvents.mission })
|
||||
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`${growEvents.correlation}->>'sessionId' = ${sessionId}`,
|
||||
sql`coalesce(${growEvents.correlation}->>'taskId', ${growEvents.correlation}->>'curatorTaskId', ${growEvents.payload}->>'taskId', ${growEvents.payload}->>'curatorTaskId') is not null`,
|
||||
))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(1);
|
||||
.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);
|
||||
@@ -112,13 +158,17 @@ async function resolveServiceSessionLink(event: GrowEventEnvelope, userId: strin
|
||||
}
|
||||
|
||||
export function normalizeServiceId(source: string): string {
|
||||
if (source.includes("interview")) return "interview";
|
||||
if (source.includes("roleplay")) return "roleplay";
|
||||
if (source.includes("resume")) return "resume";
|
||||
if (source.includes("course")) return "course";
|
||||
if (source.includes("courses")) return "course";
|
||||
if (source.includes("qscore")) return "qscore";
|
||||
return source;
|
||||
const normalized = source.toLowerCase();
|
||||
if (normalized.includes("interview")) return "interview";
|
||||
if (normalized.includes("roleplay")) return "roleplay";
|
||||
if (normalized.includes("resume")) return "resume";
|
||||
if (normalized.includes("cover-letter") || normalized.includes("cover_letter")) return "cover_letter";
|
||||
if (normalized.includes("course")) return "course";
|
||||
if (normalized.includes("qscore") || normalized.includes("q-score")) return "qscore";
|
||||
if (normalized.includes("assessment")) return "assessment";
|
||||
if (normalized.includes("matchmaking")) return "matchmaking";
|
||||
if (normalized.includes("social") || normalized.includes("brand")) return "brand";
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function recordGrowEvent(input: unknown, overrides: { userId?: string; source?: string } = {}): Promise<GrowEventRow> {
|
||||
|
||||
@@ -284,7 +284,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
mode: "video",
|
||||
},
|
||||
toolName: "prepare_roleplay_preview",
|
||||
completionEvents: ["roleplay.scenario.configured", "roleplay.scenario.completed", "roleplay.feedback.generated"],
|
||||
completionEvents: ["roleplay.scenario.completed", "roleplay.feedback.generated"],
|
||||
qscoreSignals: ["communication.roleplay", "networking.conversation", "readiness.practice"],
|
||||
usage: "Include role, brief, mission state, and curatorTaskId when building stateful handoffs.",
|
||||
},
|
||||
@@ -372,7 +372,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
tab: "resumes",
|
||||
},
|
||||
toolName: "prepare_resume_upload",
|
||||
completionEvents: ["resume.analysis.completed", "resume.parsed", "resume.updated", "resume.exported"],
|
||||
completionEvents: ["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.",
|
||||
},
|
||||
@@ -524,7 +524,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
start: "Start course",
|
||||
},
|
||||
toolName: "prepare_course_handoff",
|
||||
completionEvents: ["course.generated", "course.started", "course.progress_recorded", "course.lesson.completed", "course.completed", "course.weakness.applied"],
|
||||
completionEvents: ["course.completed"],
|
||||
qscoreSignals: ["courses.started", "courses.completed", "courses.completion_rate"],
|
||||
usage: "Use for skill-gap tasks that should become learning work.",
|
||||
},
|
||||
@@ -534,7 +534,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
openEvents: ["task.opened"],
|
||||
startedEvents: ["service.started", "course.started"],
|
||||
configuredEvents: ["course.generated"],
|
||||
completionEvents: ["service.completed", "course.progress_recorded", "course.lesson.completed", "course.completed", "course.weakness.applied"],
|
||||
completionEvents: ["service.completed", "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"],
|
||||
@@ -583,7 +583,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
review: "Review assessment",
|
||||
},
|
||||
toolName: "prepare_assessment_handoff",
|
||||
completionEvents: ["assessment.started", "assessment.completed"],
|
||||
completionEvents: ["assessment.completed"],
|
||||
qscoreSignals: ["skills.assessment", "readiness.measurement"],
|
||||
usage: "Use when a task needs a scoreable assessment rather than practice.",
|
||||
},
|
||||
@@ -646,14 +646,9 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
},
|
||||
toolName: "prepare_matchmaking_handoff",
|
||||
completionEvents: [
|
||||
"matchmaking.matches.generated",
|
||||
"matchmaking.feed.viewed",
|
||||
"matchmaking.match.viewed",
|
||||
"matchmaking.match.saved",
|
||||
"matchmaking.match.dismissed",
|
||||
"matchmaking.matches.reviewed",
|
||||
"matchmaking.match.applied",
|
||||
"matchmaking.application.started",
|
||||
"matchmaking.application.completed",
|
||||
],
|
||||
qscoreSignals: ["matching.jobs_viewed", "matching.applications_submitted", "matching.match_rate"],
|
||||
@@ -670,10 +665,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
configuredEvents: [],
|
||||
completionEvents: [
|
||||
"service.completed",
|
||||
"matchmaking.matches.generated",
|
||||
"matchmaking.match.viewed",
|
||||
"matchmaking.match.saved",
|
||||
"matchmaking.match.dismissed",
|
||||
"matchmaking.matches.reviewed",
|
||||
"matchmaking.match.applied",
|
||||
"matchmaking.application.completed",
|
||||
@@ -725,7 +717,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
review: "Review QScore",
|
||||
},
|
||||
toolName: "prepare_qscore_review",
|
||||
completionEvents: ["qscore.baseline.created", "qscore.review.opened", "qscore.weak_driver.reviewed", "qscore.signal.projected", "qscore.updated"],
|
||||
completionEvents: ["qscore.weak_driver.reviewed", "qscore.signal.projected", "qscore.updated"],
|
||||
qscoreSignals: ["qscore.updated", "qscore.signal.projected"],
|
||||
usage: "Use for measurement and projected readiness review tasks.",
|
||||
},
|
||||
@@ -785,7 +777,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
review: "Review profile",
|
||||
},
|
||||
toolName: "prepare_social_branding_handoff",
|
||||
completionEvents: ["brand.post.drafted", "brand.post.edited", "brand.post.exported", "brand.profile.updated", "brand.content.published"],
|
||||
completionEvents: ["brand.post.exported", "brand.profile.updated", "brand.content.published", "social.profile.updated", "social.post.scheduled", "social.post.published"],
|
||||
qscoreSignals: ["profile.linkedin", "proof.visibility", "networking.brand"],
|
||||
usage: "Use for profile visibility, LinkedIn cleanup, and social proof tasks.",
|
||||
},
|
||||
@@ -795,7 +787,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
openEvents: ["task.opened"],
|
||||
startedEvents: ["service.started"],
|
||||
configuredEvents: ["brand.post.drafted"],
|
||||
completionEvents: ["service.completed", "brand.post.edited", "brand.post.exported", "brand.profile.updated", "brand.content.published"],
|
||||
completionEvents: ["service.completed", "brand.post.exported", "brand.profile.updated", "brand.content.published", "social.profile.updated", "social.post.scheduled", "social.post.published"],
|
||||
abandonmentEvents: ["service.abandoned"],
|
||||
qscoreSignalIds: ["brand.visibility_signal", "profile.linkedin", "proof.visibility"],
|
||||
analyticsDimensions: ["content_type", "channel", "draft_id", "topic", "profile_field_updated"],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { and, desc, eq, gte, inArray, sql } from "drizzle-orm";
|
||||
import { db } from "../../db/client.js";
|
||||
import { growEvents, missionArtifacts, onboarding, users } from "../../db/schema.js";
|
||||
import { growEvents, missionArtifacts, onboarding, users, type GrowEventRow } from "../../db/schema.js";
|
||||
import { listMissionDefinitions } from "../../missions/registry.js";
|
||||
import { listServiceCapabilities } from "../../workflows/service-capabilities.js";
|
||||
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService } from "../../services/qscore-proxy.js";
|
||||
@@ -39,26 +39,25 @@ import { addDaysIso, computeStreakFromDays, todayIso } from "./streak-utils.js";
|
||||
const VALID_COMPLETION_TYPES = [
|
||||
"service.completed",
|
||||
"resume.analysis.completed",
|
||||
"resume.parsed",
|
||||
"resume.updated",
|
||||
"resume.exported",
|
||||
"interview.session.completed",
|
||||
"interview.feedback.generated",
|
||||
"roleplay.scenario.configured",
|
||||
"roleplay.scenario.completed",
|
||||
"roleplay.feedback.generated",
|
||||
"assessment.completed",
|
||||
"course.progress_recorded",
|
||||
"course.completed",
|
||||
"course.lesson.completed",
|
||||
"brand.post.exported",
|
||||
"brand.profile.updated",
|
||||
"matchmaking.matches.generated",
|
||||
"matchmaking.feed.viewed",
|
||||
"brand.content.published",
|
||||
"social.profile.updated",
|
||||
"social.post.scheduled",
|
||||
"social.post.published",
|
||||
"matchmaking.match.saved",
|
||||
"matchmaking.matches.reviewed",
|
||||
"matchmaking.match.applied",
|
||||
"matchmaking.application.completed",
|
||||
"qscore.weak_driver.reviewed",
|
||||
"qscore.updated",
|
||||
"qscore.signal.projected",
|
||||
"curator.task.completed",
|
||||
@@ -859,20 +858,9 @@ async function loadCurrentQScore(userId: string) {
|
||||
return result?.q_score ?? null;
|
||||
}
|
||||
|
||||
async function loadCompletionRows(userId: string, sinceDate: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(growEvents)
|
||||
.where(and(
|
||||
eq(growEvents.userId, userId),
|
||||
inArray(growEvents.type, [...VALID_COMPLETION_TYPES]),
|
||||
gte(growEvents.occurredAt, new Date(`${sinceDate}T00:00:00.000Z`)),
|
||||
))
|
||||
.orderBy(desc(growEvents.occurredAt));
|
||||
}
|
||||
export type CuratorCompletionEvent = Pick<GrowEventRow, "type" | "payload" | "correlation">;
|
||||
|
||||
function taskCompletedByTaskId(taskId: string, completionEvents: string[], rows: Awaited<ReturnType<typeof loadCompletionRows>>) {
|
||||
return rows.some((row) => {
|
||||
export function isTaskCompletionEvent(taskId: string, completionEvents: string[], row: CuratorCompletionEvent) {
|
||||
const payload = row.payload ?? {};
|
||||
const correlation = row.correlation ?? {};
|
||||
const eventTaskId =
|
||||
@@ -887,7 +875,22 @@ function taskCompletedByTaskId(taskId: string, completionEvents: string[], rows:
|
||||
row.type === "service.completed" ||
|
||||
completionEvents.includes(row.type)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadCompletionRows(userId: string, sinceDate: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(growEvents)
|
||||
.where(and(
|
||||
eq(growEvents.userId, userId),
|
||||
inArray(growEvents.type, [...VALID_COMPLETION_TYPES]),
|
||||
gte(growEvents.occurredAt, new Date(`${sinceDate}T00:00:00.000Z`)),
|
||||
))
|
||||
.orderBy(desc(growEvents.occurredAt));
|
||||
}
|
||||
|
||||
function taskCompletedByTaskId(taskId: string, completionEvents: string[], rows: CuratorCompletionEvent[]) {
|
||||
return rows.some((row) => isTaskCompletionEvent(taskId, completionEvents, row));
|
||||
}
|
||||
|
||||
function taskCompletedByEvents(task: CuratorTask, rows: Awaited<ReturnType<typeof loadCompletionRows>>) {
|
||||
@@ -1285,7 +1288,11 @@ export async function buildCuratorStreak(userId: string): Promise<CuratorStreak>
|
||||
day: sql<string>`to_char(${growEvents.occurredAt} AT TIME ZONE 'Asia/Kolkata', 'YYYY-MM-DD')`,
|
||||
})
|
||||
.from(growEvents)
|
||||
.where(and(eq(growEvents.userId, userId), inArray(growEvents.type, [...VALID_COMPLETION_TYPES])))
|
||||
.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`,
|
||||
))
|
||||
.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`)
|
||||
.limit(90);
|
||||
|
||||
Reference in New Issue
Block a user