PRM-71 Backend QA Curator streak loop #10

Merged
dv merged 4 commits from prm-71-backend-qa-curator-streak-loop into staging 2026-06-23 21:26:55 +00:00
9 changed files with 662 additions and 19 deletions

View File

@@ -0,0 +1,154 @@
# PRM-71 Backend QA Evidence
This file keeps the PRM-71 backend QA proof inside the backend PR. The checks below were run against the real deployed API at `https://app.sai-onchain.me/api/growqr`, not against mocks or fallback-only fixtures.
## Deployed Target
- Public backend base: `https://app.sai-onchain.me/api/growqr`
- Local backend base on VPS: `http://127.0.0.1:4000`
- Branch: `prm-71-backend-qa-curator-streak-loop`
- Runtime implementation commit verified: `0bfc18305bd2462fc7c0fcbfb2a3f5cd76df3f9d`
- PR: `https://git.openputer.com/growqr-app/growqr-backend/pulls/10`
## Service Commit SHAs
- `growqr-backend`: `0bfc18305bd2462fc7c0fcbfb2a3f5cd76df3f9d`
- `growqr-dashboard`: `c4e79d7a17767a083f19f02ba1ca4065f1d415d7`
- `interview-service`: `61b238b00463bc3a1e283bf3b850c97279d94ece`
- `roleplay-service`: `b4a4913df28c00985578e3af5f1a95e12cf4260e`
- `resume-service`: `ebcc6e0826c2e7762251080b6365ebb6b5439c93`
- `qscore-service`: `058903f9686067398640a6a56aebce0b57408ccb`
- `matchmaking-service`: `e36e831794cccb0e176df4e9113ab1957d4c3612`
- `courses-service`: `f702728247bb4e66edf4552d792d25825ceb44fe`
- `assessment-service`: `d2885ad2c83c86a95b6a8d9a46dafe5415678422`
- `pathways-service`: `b20abed9d7a5fb9c68804b986a9d46a1015d54af`
- `social-branding-service`: `98463cdcf75f720a3035c2954b2a847956df24f2`
## Health Proof
- Backend container: `growqr-backend Up ... (healthy)`
- Local backend health: `GET http://127.0.0.1:4000/healthz` returned `{"ok":true}`
- Public API health was exercised through authenticated real API calls at `https://app.sai-onchain.me/api/growqr/...`
- Gateway health passed for `interview`, `roleplay`, `resume`, and `social`
- Direct declared health paths passed for `qscore-service`, `matchmaking-service`, `courses-service`, `assessment-service`, and `pathways-service`
## Real API Evidence Users
- Full evidence flow user: `qa-prm71-full-flow-1782248569`
- Full handoff sample user: `qa-prm71-handoffs-1782248569`
- Final battle-test flow user: `qa-prm71-battle-flow-1782248509`
- Final battle-test all-complete user: `qa-prm71-battle-complete-1782248509`
## API Contract Evidence
The full evidence run captured:
- `GET /v1/curator/today?date=2026-06-23` for a fresh test seeker
- `POST /v1/curator/tasks/:taskId/handoff` samples for:
- `interview-service`
- `roleplay-service`
- `resume-service`
- `qscore-service`
- `POST /v1/events/track` sample payloads for:
- `service.started`
- `service.abandoned`
- `service.completed`
- `GET /v1/qscore/latest` before and after completion
- `GET /v1/analytics/insight-snapshot` before and after completion
- `GET /v1/analytics/activity-history` after event ingestion
The battle-test run additionally checked auth rejection, malformed event rejection, idempotent duplicate event replay, cross-user isolation, large activity-history limit clamping, all-complete Day 1 behavior, and recovery Day 2 behavior.
## Day 1 To Day 2 Replan Proof
Fresh seeker flow:
- Day 1 returned exactly 3 tasks: `measurement`, `proof`, `practice`
- A practice handoff recorded `task.opened`
- Real event payloads recorded `service.started` and `service.abandoned`
- Day 2 returned 4 tasks with a `recovery` task
- Day 1 statuses after replan included `skipped`, `skipped`, and `abandoned`
- Adaptation reason: `day 1 incomplete: 1 abandoned/partial, 2 skipped`
All-complete control flow:
- Day 1 tasks were completed with real `service.completed` events
- Duplicate completion replays returned idempotent responses
- Day 2 did not include a recovery task
- Day 1 statuses were all `completed`
## QScore And Analytics Proof
- QScore before completion: `null` / `baseline_needed`
- QScore after completion: `89` / `ready`
- Analytics roleFit before completion: `baseline_needed`
- Analytics roleFit after completion: `strong` with score `89`
- Follow-up battle test verified a scored `service.completed` event updates QScore/readiness state, closing the earlier gap where generic scored completions could process without moving QScore.
## Event Storage Proof
Database proof for the full evidence flow:
```text
curator.day.opened|pending|4
curator.onboarding_plan.ready|pending|1
curator.sprint.started|pending|1
service.abandoned|processed|1
service.completed|processed|1
service.started|processed|1
task.opened|pending|2
```
API proof was also captured through `GET /v1/analytics/activity-history`, which returned the ingested event stream for the test seeker.
## Battle-Test Checklist
Final battle-test result on the deployed real API: `23/23` checks passed.
- [x] Public health endpoint is reachable
- [x] Protected endpoint rejects missing auth
- [x] Event contract rejects missing type/action
- [x] Fresh QScore is `baseline_needed`
- [x] Fresh analytics roleFit is `baseline_needed`
- [x] Onboarding run succeeds
- [x] Day 1 returns three frontend-consumable tasks
- [x] Day 1 tasks include service routing metadata
- [x] Curator handoff succeeds
- [x] `service.started` processes
- [x] Duplicate started event is idempotent
- [x] `service.abandoned` processes
- [x] Day 2 adds recovery after abandoned Day 1
- [x] Day 1 statuses reflect skipped/abandoned work
- [x] `service.completed` processes
- [x] Duplicate completed event is idempotent
- [x] QScore updates after real completion
- [x] Analytics updates after real completion
- [x] Activity history clamps large limits
- [x] Duplicate completed event is stored only once
- [x] All-complete Day 1 has no recovery on Day 2
- [x] All-complete Day 1 statuses are completed
- [x] Payload `userId` cannot write into another user's stream
## Rollback Notes
If the deployed VPS backend must be rolled back to staging:
```bash
cd /opt/growqr/growqr-backend
git fetch origin --prune
git checkout staging
git reset --hard origin/staging
docker compose up -d --build backend
curl -fsS http://127.0.0.1:4000/healthz
```
Revert alternative from the PR branch:
```bash
git revert $(git rev-list --reverse origin/staging..HEAD)
docker compose up -d --build backend
```
## Current Formal Caveat
PRM-71's real API/backend production-slice evidence is satisfied by this PR and the deployed checks above. The Linear parent DoD also says grouped backend child issues must be merged/deployed or explicitly deferred with owner approval. At the time of this evidence pass, the PRM-71 parent has PR #10 attached and several grouped child Linear issues are still not formally marked done in Linear. This PR therefore provides the deployed PRM-71 proof, while final parent closure still depends on the owner's desired handling of those child issue statuses.

View File

@@ -113,11 +113,57 @@ function extractRoleplaySignals(event: GrowEventRow): QscoreSignal[] {
return signals;
}
function sourceSignalPrefix(source: string) {
return source
.toLowerCase()
.replace(/-service$/, "")
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "") || "service";
}
function extractScoredServiceSignals(event: GrowEventRow): QscoreSignal[] {
const payload = event.payload ?? {};
const review = asRecord(payload.review ?? payload.result ?? payload);
const status = String(review.status ?? payload.status ?? "");
const isCompletion =
event.type.includes("completed") ||
event.type.includes("updated") ||
event.type.includes("signal_projected") ||
status === "completed";
if (!isCompletion) return [];
const score = getNumber(
payload.score ??
payload.qscore ??
payload.q_score ??
payload.readiness_score ??
payload.overall_score ??
review.score ??
review.qscore ??
review.q_score ??
review.readiness_score ??
review.overall_score,
);
if (score === undefined) return [];
const prefix = sourceSignalPrefix(event.source);
return [
signal(`${prefix}.service_completion_score`, score, {
eventId: event.id,
source: event.source,
type: event.type,
}),
];
}
export function extractQscoreSignals(event: GrowEventRow): QscoreSignal[] {
const source = event.source.toLowerCase();
if (source.includes("resume") || event.type.startsWith("resume.")) return extractResumeSignals(event);
if (source.includes("interview") || event.type.startsWith("interview.")) return extractInterviewSignals(event);
if (source.includes("roleplay") || event.type.startsWith("roleplay.")) return extractRoleplaySignals(event);
if (source.includes("qscore") || event.type.startsWith("qscore.")) return extractScoredServiceSignals(event);
const scoredServiceSignals = extractScoredServiceSignals(event);
if (scoredServiceSignals.length) return scoredServiceSignals;
if (event.type === "mission.interview_to_offer.started") {
return [signal("goals.goals_set", 100, { eventId: event.id })];
}

View File

@@ -1,8 +1,25 @@
import { Hono } from "hono";
import { z } from "zod";
import { and, desc, eq, gte, sql } from "drizzle-orm";
import { requireUser, type AuthContext } from "../../auth/clerk.js";
import { db } from "../../db/client.js";
import { growEvents, growQscoreLatest, growQscoreProjectionState } from "../../db/schema.js";
import { v1AnalyticsActor } from "./analytics-actor.js";
function daysAgo(days: number) {
return new Date(Date.now() - days * 86400000);
}
function sourceBucket(source: string) {
if (source.includes("interview")) return "interview";
if (source.includes("roleplay")) return "roleplay";
if (source.includes("resume")) return "resume";
if (source.includes("qscore")) return "qscore";
if (source.includes("curator")) return "curator";
if (source.includes("match")) return "opportunities";
return source || "unknown";
}
export function v1AnalyticsRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
@@ -19,6 +36,98 @@ export function v1AnalyticsRoutes() {
return c.json(await v1AnalyticsActor.getUserActivity({ userId }));
});
app.get("/insight-snapshot", async (c) => {
const userId = c.get("userId");
const [projection] = await db
.select()
.from(growQscoreProjectionState)
.where(eq(growQscoreProjectionState.userId, userId))
.limit(1);
const latestSignals = await db
.select()
.from(growQscoreLatest)
.where(eq(growQscoreLatest.userId, userId))
.orderBy(desc(growQscoreLatest.updatedAt))
.limit(20);
const recentEvents = await db
.select()
.from(growEvents)
.where(and(eq(growEvents.userId, userId), gte(growEvents.occurredAt, daysAgo(14))))
.orderBy(desc(growEvents.occurredAt))
.limit(100);
const [counts] = await db
.select({
total: sql<number>`count(*)::int`,
completed: sql<number>`count(*) filter (where ${growEvents.type} ilike '%completed%' or ${growEvents.type} ilike '%review_completed%')::int`,
opened: sql<number>`count(*) filter (where ${growEvents.type} = 'task.opened' or ${growEvents.type} ilike '%started%')::int`,
})
.from(growEvents)
.where(and(eq(growEvents.userId, userId), gte(growEvents.occurredAt, daysAgo(14))));
const serviceCounts = new Map<string, number>();
for (const event of recentEvents) {
const bucket = sourceBucket(event.source);
serviceCounts.set(bucket, (serviceCounts.get(bucket) ?? 0) + 1);
}
const score = projection?.score ?? null;
const strongestSignal = [...latestSignals].sort((a, b) => b.score - a.score)[0];
const weakestSignal = [...latestSignals].sort((a, b) => a.score - b.score)[0];
return c.json({
roleFit: {
score,
label: score === null ? "baseline_needed" : score >= 75 ? "strong" : score >= 55 ? "building" : "needs_focus",
strongestSignal: strongestSignal?.signalId ?? null,
weakestSignal: weakestSignal?.signalId ?? null,
},
readinessTrend: {
signalCount: projection?.signalCount ?? latestSignals.length,
lastUpdatedAt: projection?.updatedAt?.toISOString() ?? latestSignals[0]?.updatedAt?.toISOString() ?? null,
summary: projection?.summary ?? "No projected readiness summary is available yet.",
},
activity: {
totalEvents14d: counts?.total ?? 0,
completedEvents14d: counts?.completed ?? 0,
openedEvents14d: counts?.opened ?? 0,
services: Array.from(serviceCounts.entries()).map(([service, count]) => ({ service, count })),
},
opportunities: {
events14d: recentEvents.filter((event) => sourceBucket(event.source) === "opportunities").length,
latestEventAt: recentEvents.find((event) => sourceBucket(event.source) === "opportunities")?.occurredAt.toISOString() ?? null,
},
source: "grow_events",
});
});
app.get("/activity-history", async (c) => {
const userId = c.get("userId");
const limit = Math.min(200, Math.max(1, Number(c.req.query("limit") ?? 80)));
const since = c.req.query("since");
const sinceDate = since ? new Date(since) : daysAgo(30);
const rows = await db
.select()
.from(growEvents)
.where(and(eq(growEvents.userId, userId), gte(growEvents.occurredAt, Number.isNaN(sinceDate.getTime()) ? daysAgo(30) : sinceDate)))
.orderBy(desc(growEvents.occurredAt))
.limit(limit);
return c.json({
events: rows.map((event) => ({
id: event.id,
source: event.source,
type: event.type,
category: event.category,
occurredAt: event.occurredAt.toISOString(),
processingStatus: event.processingStatus,
mission: event.mission,
subject: event.subject,
correlation: event.correlation,
payload: event.payload,
})),
count: rows.length,
source: "grow_events",
});
});
app.post("/nightly/run", async (c) => {
const userId = c.get("userId");
const body = z.object({

View File

@@ -21,6 +21,7 @@ import type {
import { buildCuratorTaskDeepLink, completionEventsForService, serviceName, serviceToolName } from "./curator-service-links.js";
const VALID_COMPLETION_TYPES = [
"service.completed",
"resume.analysis_completed",
"resume.parsed",
"resume.updated",
@@ -82,7 +83,7 @@ type PlanDaySeed = {
weekTheme: string;
weekSummary: string;
focus: string;
plannedTasks: [PlannedTask, PlannedTask, PlannedTask];
plannedTasks: PlannedTask[];
generationStatus: "seeded" | "generated" | "adapted";
adaptationReason?: string;
};
@@ -873,6 +874,7 @@ function planWeekCount(startDate: string) {
function planFocus(weekTheme: string, seedTask: TaskSeed) {
if (seedTask.taskType === "measurement") return `Measure today against the ${weekTheme.toLowerCase()} theme.`;
if (seedTask.taskType === "proof") return `Turn progress from ${weekTheme.toLowerCase()} into visible proof.`;
if (seedTask.taskType === "recovery") return `Recover momentum from the last incomplete day before adding more work.`;
return `Practice one concrete move that advances ${weekTheme.toLowerCase()}.`;
}
@@ -1058,6 +1060,13 @@ function performanceLabel(percent: number): CuratorWeek["performance"] {
}
function subtaskCopy(seedTask: TaskSeed, weekTheme: string) {
if (seedTask.taskType === "recovery") {
return [
"Review the missed or abandoned task trail",
"Choose the smallest useful recovery action",
`Save the next move for ${weekTheme.toLowerCase()}`,
];
}
if (seedTask.taskType === "measurement") {
return [
"Open the current score or readiness view",
@@ -1080,6 +1089,13 @@ function subtaskCopy(seedTask: TaskSeed, weekTheme: string) {
}
function contextNarrative(seedTask: TaskSeed, weekTheme: string, weekSummary: string) {
if (seedTask.taskType === "recovery") {
return [
`This recovery task appears because the previous curator day was not fully completed.`,
weekSummary,
"Use the linked service to convert the missed, partial, or abandoned work into a constructive next action.",
].join(" ");
}
return [
`This ${seedTask.taskType} task belongs to the ${weekTheme} week of the curator sprint.`,
weekSummary,
@@ -1123,7 +1139,7 @@ function templateSetFor(variantId: CuratorIcpId) {
function clonePlanDay(day: PlanDaySeed): PlanDaySeed {
return {
...day,
plannedTasks: day.plannedTasks.map((task) => ({ ...task })) as PlanDaySeed["plannedTasks"],
plannedTasks: day.plannedTasks.map((task) => ({ ...task })),
};
}
@@ -1203,7 +1219,13 @@ async function loadSprintState(userId: string, todayDate = todayIso()): Promise<
async function loadRecentContextRows(userId: string, sinceDate: string) {
return db
.select({ type: growEvents.type, source: growEvents.source, payload: growEvents.payload, occurredAt: growEvents.occurredAt })
.select({
type: growEvents.type,
source: growEvents.source,
payload: growEvents.payload,
correlation: growEvents.correlation,
occurredAt: growEvents.occurredAt,
})
.from(growEvents)
.where(and(eq(growEvents.userId, userId), gte(growEvents.occurredAt, new Date(`${sinceDate}T00:00:00.000Z`))))
.orderBy(desc(growEvents.occurredAt))
@@ -1235,8 +1257,18 @@ function taskCompletedByTaskId(taskId: string, completionEvents: string[], rows:
return rows.some((row) => {
const payload = row.payload ?? {};
const correlation = row.correlation ?? {};
const eventTaskId = payload.taskId ?? correlation.taskId;
return eventTaskId === taskId && (row.type === "curator.task.completed" || completionEvents.includes(row.type));
const eventTaskId =
payload.taskId ??
payload.curatorTaskId ??
payload.curator_task_id ??
correlation.taskId ??
correlation.curatorTaskId ??
correlation.curator_task_id;
return eventTaskId === taskId && (
row.type === "curator.task.completed" ||
row.type === "service.completed" ||
completionEvents.includes(row.type)
);
});
}
@@ -1248,6 +1280,71 @@ function hasRecentServiceSignal(rows: Awaited<ReturnType<typeof loadRecentContex
return rows.some((row) => pattern.test(`${row.source} ${row.type} ${eventText(row.payload)}`.toLowerCase()));
}
function taskEventId(row: Pick<Awaited<ReturnType<typeof loadRecentContextRows>>[number], "payload" | "correlation">) {
const payload = row.payload ?? {};
const correlation = row.correlation ?? {};
const value =
payload.taskId ??
payload.curatorTaskId ??
payload.curator_task_id ??
correlation.taskId ??
correlation.curatorTaskId ??
correlation.curator_task_id;
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function rowsForTask(taskId: string, rows: Awaited<ReturnType<typeof loadRecentContextRows>>) {
return rows.filter((row) => taskEventId(row) === taskId);
}
function taskOpenedOrStarted(taskRows: Awaited<ReturnType<typeof loadRecentContextRows>>) {
return taskRows.some((row) => (
row.type === "task.opened" ||
row.type === "curator.task.started" ||
row.type === "curator.service_handoff.opened" ||
row.type.includes("started") ||
row.type.includes("configured") ||
row.type.includes("abandoned") ||
row.payload?.status === "abandoned"
));
}
function taskHandoffPrepared(taskRows: Awaited<ReturnType<typeof loadRecentContextRows>>) {
return taskRows.some((row) => row.type === "task.opened" || row.type === "curator.service_handoff.opened");
}
function classifyTaskStatus(input: {
task: CuratorTask;
focusDate: string;
completionRows: Awaited<ReturnType<typeof loadCompletionRows>>;
recentRows: Awaited<ReturnType<typeof loadRecentContextRows>>;
}): CuratorTask["status"] {
if (taskCompletedByEvents(input.task, input.completionRows)) return "completed";
const taskRows = rowsForTask(input.task.id, input.recentRows);
if (input.task.date < input.focusDate) {
return taskOpenedOrStarted(taskRows) ? "abandoned" : "skipped";
}
if (taskHandoffPrepared(taskRows)) return "handoff_prepared";
if (taskOpenedOrStarted(taskRows)) return "started";
return "ready";
}
function recoveryTaskSeed(previousDayIndex: number, openedIncompleteCount: number, skippedCount: number): TaskSeed {
const reason = openedIncompleteCount > 0
? `${openedIncompleteCount} opened task${openedIncompleteCount === 1 ? "" : "s"} did not produce completion events`
: `${skippedCount} planned task${skippedCount === 1 ? "" : "s"} had no opened or completion events`;
return seed(
"recovery",
"qscore-service",
`Recover Day ${previousDayIndex} momentum`,
`Yesterday's event trail shows ${reason}. Review the blocker, pick one constructive adjustment, and keep the streak aligned.`,
"5 min",
"+5 projected",
"Review Q Score",
["recovery", "alignment", "event trail"],
);
}
function adaptCurrentDayPlan(
sprintStartDate: string,
focusDayIndex: number,
@@ -1277,7 +1374,7 @@ function adaptCurrentDayPlan(
reasons.push("resume baseline missing");
}
if (!hasInterviewSignal && !hasRoleplaySignal && focusDayIndex >= 3 && current.plannedTasks[2].serviceId === "matchmaking-service") {
if (!hasInterviewSignal && !hasRoleplaySignal && focusDayIndex >= 3 && current.plannedTasks[2]?.serviceId === "matchmaking-service") {
current.plannedTasks[2] = makePlannedTask(
seed("practice", "interview-service", "Run a real interview warm-up rep", "Start a live interview-style practice so the sprint captures real readiness movement, not just planning.", "10 min", "+10 projected", "Open interview preview", ["interview warm-up", "first rep"]),
current.weekTheme,
@@ -1302,14 +1399,32 @@ function adaptCurrentDayPlan(
)).length;
if (previousCompleted < 3) {
const previousClassifications = previous.plannedTasks.map((task) => {
const taskId = taskIdFor(sprintStartDate, previous.dayIndex, task.taskType);
const taskRows = rowsForTask(taskId, recentRows);
return {
taskId,
opened: taskOpenedOrStarted(taskRows),
completed: taskCompletedByTaskId(taskId, completionEventsForService(task.serviceId), completionRows),
};
});
const openedIncompleteCount = previousClassifications.filter((item) => item.opened && !item.completed).length;
const skippedCount = previousClassifications.filter((item) => !item.opened && !item.completed).length;
current.plannedTasks[0] = makePlannedTask(
seed("measurement", "qscore-service", `Review what blocked Day ${previous.dayIndex}`, "Check the strongest blocker from yesterday before generating more blind work.", "5 min", "+5 projected", "Review Q Score", ["blocker review", "momentum"]),
current.weekTheme,
current.weekSummary,
);
if (!current.plannedTasks.some((task) => task.taskType === "recovery")) {
current.plannedTasks.push(makePlannedTask(
recoveryTaskSeed(previous.dayIndex, openedIncompleteCount, skippedCount),
current.weekTheme,
current.weekSummary,
));
}
current.focus = `Review what blocked Day ${previous.dayIndex}, then continue with todays proof and practice plan.`;
adapted = true;
reasons.push(`day ${previous.dayIndex} incomplete`);
reasons.push(`day ${previous.dayIndex} incomplete: ${openedIncompleteCount} abandoned/partial, ${skippedCount} skipped`);
}
}
@@ -1342,7 +1457,7 @@ function adaptCurrentDayPlan(
current.generationStatus = "generated";
}
current.plannedTasks = current.plannedTasks.map((task) => normalizeEventBackedTask(task, current, recentRows)) as PlanDaySeed["plannedTasks"];
current.plannedTasks = current.plannedTasks.map((task) => normalizeEventBackedTask(task, current, recentRows));
plan[focusDayIndex - 1] = current;
return plan;
@@ -1357,6 +1472,8 @@ function buildTask(
weekSummary: string,
seedTask: TaskSeed,
completionRows: Awaited<ReturnType<typeof loadCompletionRows>>,
recentRows: Awaited<ReturnType<typeof loadRecentContextRows>>,
focusDate: string,
targetRole?: string,
): CuratorTask {
const dayOfWeek = dayIndexInWeek(sprintStartDate, dayIndex);
@@ -1380,7 +1497,7 @@ function buildTask(
actorName: "Curator sprint planner",
toolName: serviceToolName(seedTask.serviceId),
status: "ready",
rewardCoins: seedTask.taskType === "measurement" ? 12 : seedTask.taskType === "proof" ? 15 : 18,
rewardCoins: seedTask.taskType === "measurement" ? 12 : seedTask.taskType === "proof" ? 15 : seedTask.taskType === "recovery" ? 8 : 18,
qxImpact: seedTask.qxImpact,
effort: seedTask.effort,
route: "",
@@ -1401,7 +1518,7 @@ function buildTask(
const taskWithRoute = {
...task,
route: buildCuratorTaskDeepLink(task, targetRole),
status: taskCompletedByEvents(task, completionRows) ? "completed" as const : "ready" as const,
status: classifyTaskStatus({ task, focusDate, completionRows, recentRows }),
} satisfies CuratorTask;
return taskWithRoute;
}
@@ -1411,6 +1528,8 @@ function buildTasksForPlanDay(
date: string,
planDay: PlanDaySeed,
completionRows: Awaited<ReturnType<typeof loadCompletionRows>>,
recentRows: Awaited<ReturnType<typeof loadRecentContextRows>>,
focusDate: string,
targetRole?: string,
) {
return planDay.plannedTasks.map((task) => (
@@ -1423,6 +1542,8 @@ function buildTasksForPlanDay(
planDay.weekSummary,
task,
completionRows,
recentRows,
focusDate,
targetRole,
)
));
@@ -1446,7 +1567,7 @@ export async function buildCuratorTasks(userId: string, date = todayIso()): Prom
);
const planDay = adaptedPlanDays[dayIndex - 1];
if (!planDay) return [];
return buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows, userContext.targetRole);
return buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows, recentRows, date, userContext.targetRole);
}
export async function buildCuratorStreak(userId: string): Promise<CuratorStreak> {
@@ -1505,7 +1626,9 @@ async function buildCuratorSprintInternal(userId: string, focusDate = todayIso()
const dayIndex = index + 1;
const date = addDaysIso(sprintStartDate, index);
const planDay = planDays[index]!;
const tasks = date <= focusDate ? buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows, userContext.targetRole) : [];
const tasks = date <= focusDate
? buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows, recentRows, focusDate, userContext.targetRole)
: [];
const completedCount = tasks.filter((task) => task.status === "completed").length;
const unlockState = focusDate > date ? "completed" : focusDate === date ? "active" : "upcoming";
days.push({
@@ -1520,7 +1643,7 @@ async function buildCuratorSprintInternal(userId: string, focusDate = todayIso()
generationStatus: planDay.generationStatus,
adaptationReason: planDay.adaptationReason,
completedCount,
totalCount: 3,
totalCount: planDay.plannedTasks.length,
unlockState,
tasks,
});
@@ -1622,7 +1745,7 @@ export async function buildServiceCurationPreview(input: ServiceCurationPreviewI
return {
...planDay,
date,
tasks: buildTasksForPlanDay(startDate, date, planDay, completionRows, userContext.targetRole),
tasks: buildTasksForPlanDay(startDate, date, planDay, completionRows, [], date, userContext.targetRole),
};
});

View File

@@ -176,9 +176,16 @@ export async function prepareHandoffForTask(
await emitCuratorEvent({
userId,
type: "curator.service_handoff.opened",
type: "task.opened",
mission: { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId },
payload: { taskId: task.id, serviceId, route, actionId },
payload: {
taskId: task.id,
curatorTaskId: task.id,
serviceId,
route,
actionId,
expectedCompletionEvents: task.completionEvents,
},
});
return {

View File

@@ -15,7 +15,7 @@ export const curatorServiceIdSchema = z.enum([
export type CuratorServiceId = z.infer<typeof curatorServiceIdSchema>;
export const curatorTaskTypeSchema = z.enum(["measurement", "proof", "practice"]);
export const curatorTaskTypeSchema = z.enum(["measurement", "proof", "practice", "recovery"]);
export type CuratorTaskType = z.infer<typeof curatorTaskTypeSchema>;
export const curatorTaskStatusSchema = z.enum([
@@ -24,6 +24,9 @@ export const curatorTaskStatusSchema = z.enum([
"handoff_prepared",
"completed",
"blocked",
"partial",
"skipped",
"abandoned",
]);
export const curatorWeekLifecycleSchema = z.enum(["done", "active", "upcoming"]);
@@ -73,7 +76,7 @@ export const curatorPlanDaySchema = z.object({
weekTheme: z.string(),
weekSummary: z.string(),
focus: z.string().optional(),
plannedServices: z.array(curatorServiceIdSchema).max(3).default([]),
plannedServices: z.array(curatorServiceIdSchema).max(4).default([]),
generationStatus: z.enum(["seeded", "generated", "adapted"]).default("seeded"),
adaptationReason: z.string().optional(),
completedCount: z.number().int().min(0),
@@ -132,7 +135,7 @@ export const curatorSprintResponseSchema = z.object({
activeWeekIndex: z.number().int().min(1).max(6),
activeDay: curatorPlanDaySchema,
activeDayIndex: z.number().int().min(1).max(30),
todayTasks: z.array(curatorTaskSchema).length(3),
todayTasks: z.array(curatorTaskSchema).min(3).max(4),
streak: curatorStreakSchema,
completedCount: z.number().int().min(0),
totalCount: z.number().int().min(0),

View File

@@ -0,0 +1,113 @@
import { Hono } from "hono";
import { z } from "zod";
import { requireUser, type AuthContext } from "../../auth/clerk.js";
import { applyQscoreProjection } from "../../events/projectors/qscore-projector.js";
import { applyServiceSessionProjection } from "../../events/projectors/service-session-projector.js";
import { markGrowEventFailed, markGrowEventProcessed, markGrowEventProcessing, recordGrowEvent } from "../../events/record-grow-event.js";
import { runCuratorOnboardingLoopForEventSafely } from "../curator/curator-onboarding-loop.js";
const eventTrackSchema = z.object({
id: z.string().optional(),
source: z.string().min(1),
type: z.string().min(1).optional(),
action: z.string().min(1).optional(),
category: z.enum(["mission", "service", "artifact", "usage", "qscore", "entitlement", "system"]).default("service"),
userId: z.string().optional(),
user_id: z.string().optional(),
orgId: z.string().optional(),
org_id: z.string().optional(),
timestamp: z.string().optional(),
occurredAt: z.string().optional(),
occurred_at: z.string().optional(),
mission: z.record(z.unknown()).optional(),
subject: z.record(z.unknown()).optional(),
correlation: z.record(z.unknown()).optional(),
metadata: z.record(z.unknown()).optional(),
payload: z.record(z.unknown()).optional(),
dedupeKey: z.string().optional(),
dedupe_key: z.string().optional(),
taskId: z.string().optional(),
curatorTaskId: z.string().optional(),
curator_task_id: z.string().optional(),
serviceId: z.string().optional(),
service_id: z.string().optional(),
});
function compactRecord(value: Record<string, unknown>) {
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
}
export function v1EventRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
app.post("/track", async (c) => {
const authUserId = c.get("userId");
const body = eventTrackSchema.parse(await c.req.json());
const type = body.type ?? body.action;
if (!type) return c.json({ error: "event_type_required" }, 400);
const payload = {
...(body.payload ?? {}),
...(body.metadata ? { metadata: body.metadata } : {}),
taskId: body.taskId,
curatorTaskId: body.curatorTaskId ?? body.curator_task_id ?? body.taskId,
serviceId: body.serviceId ?? body.service_id,
status: (body.payload?.status ?? body.metadata?.status) as unknown,
};
const correlation = compactRecord({
...(body.correlation ?? {}),
taskId: body.taskId,
curatorTaskId: body.curatorTaskId ?? body.curator_task_id ?? body.taskId,
serviceId: body.serviceId ?? body.service_id,
});
const event = await recordGrowEvent({
id: body.id,
source: body.source,
type,
category: body.category,
userId: authUserId,
orgId: body.orgId ?? body.org_id,
occurredAt: body.occurredAt ?? body.occurred_at ?? body.timestamp ?? new Date().toISOString(),
mission: body.mission,
subject: body.subject,
correlation,
payload: compactRecord(payload),
raw: body,
dedupeKey: body.dedupeKey ?? body.dedupe_key ?? body.id,
}, { userId: authUserId, source: body.source });
if (event.processingStatus === "processed") {
return c.json({
eventId: event.id,
processingStatus: "processed",
idempotent: true,
}, 202);
}
await markGrowEventProcessing(event.id);
try {
const serviceSession = await applyServiceSessionProjection(event);
const qscore = await applyQscoreProjection(event);
const curatorOnboarding = await runCuratorOnboardingLoopForEventSafely(event);
await markGrowEventProcessed(event.id);
return c.json({
eventId: event.id,
processingStatus: "processed",
serviceSession,
qscore,
curatorOnboarding,
}, 202);
} catch (err) {
await markGrowEventFailed(event.id, err);
return c.json({
eventId: event.id,
processingStatus: "failed",
error: err instanceof Error ? err.message : String(err),
}, 500);
}
});
return app;
}

View File

@@ -1,10 +1,14 @@
import { Hono } from "hono";
import { v1CuratorRoutes } from "./curator/curator-routes.js";
import { v1AnalyticsRoutes } from "./analytics/analytics-routes.js";
import { v1EventRoutes } from "./events/events-routes.js";
import { v1QscoreRoutes } from "./qscore/qscore-routes.js";
export function v1Routes() {
const app = new Hono();
app.route("/curator", v1CuratorRoutes());
app.route("/analytics", v1AnalyticsRoutes());
app.route("/events", v1EventRoutes());
app.route("/qscore", v1QscoreRoutes());
return app;
}

View File

@@ -0,0 +1,84 @@
import { Hono } from "hono";
import { desc, eq } from "drizzle-orm";
import { requireUser, type AuthContext } from "../../auth/clerk.js";
import { db } from "../../db/client.js";
import { growQscoreLatest, growQscoreProjectionState } from "../../db/schema.js";
function groupDimensions(signals: Array<typeof growQscoreLatest.$inferSelect>) {
const grouped = new Map<string, { score: number; count: number; sources: Set<string> }>();
for (const signal of signals) {
const id = signal.signalId.split(".")[0] || "readiness";
const current = grouped.get(id) ?? { score: 0, count: 0, sources: new Set<string>() };
current.score += signal.score;
current.count += 1;
if (signal.source) current.sources.add(signal.source);
grouped.set(id, current);
}
return Array.from(grouped.entries()).map(([id, group]) => ({
id,
label: id.replace(/-/g, " ").replace(/^./, (char) => char.toUpperCase()),
score: Math.round(group.score / Math.max(group.count, 1)),
signalCount: group.count,
sources: Array.from(group.sources),
}));
}
export function v1QscoreRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
app.get("/latest", async (c) => {
const userId = c.get("userId");
const [projection] = await db
.select()
.from(growQscoreProjectionState)
.where(eq(growQscoreProjectionState.userId, userId))
.limit(1);
const signals = await db
.select()
.from(growQscoreLatest)
.where(eq(growQscoreLatest.userId, userId))
.orderBy(desc(growQscoreLatest.updatedAt));
if (!projection && signals.length === 0) {
return c.json({
status: "baseline_needed",
score: null,
dimensions: [],
trendLabel: "No QScore signals yet",
lastUpdatedAt: null,
explanation: "No onboarding, service completion, or readiness signals have been projected for this user.",
signalCount: 0,
signals: [],
source: "grow_qscore_projection_state",
});
}
const score = projection?.score && projection.score > 0
? projection.score
: Math.round(signals.reduce((sum, signal) => sum + signal.score, 0) / Math.max(signals.length, 1));
const lastUpdatedAt = projection?.updatedAt ?? signals[0]?.updatedAt ?? null;
return c.json({
status: "ready",
score,
dimensions: groupDimensions(signals),
trendLabel: signals.length > 1 ? "Updated from recent activity" : "Baseline established",
lastUpdatedAt: lastUpdatedAt?.toISOString() ?? null,
explanation: projection?.summary ?? `Readiness score computed from ${signals.length} current signal${signals.length === 1 ? "" : "s"}.`,
signalCount: projection?.signalCount ?? signals.length,
signals: signals.map((signal) => ({
signalId: signal.signalId,
score: Math.round(signal.score),
present: signal.present,
source: signal.source,
sourceEventId: signal.sourceEventId,
occurredAt: signal.occurredAt.toISOString(),
updatedAt: signal.updatedAt.toISOString(),
})),
source: "grow_qscore_projection_state",
});
});
return app;
}