feat(backend): REST-authoritative qscore pipeline with Redis opt-in

- Gate both Redis ingestion paths (canonical + legacy) behind separate
  default-off flags (GROW_EVENTS_REDIS_ENABLED, LEGACY_SERVICE_REDIS_ENABLED).
  The redis module is never imported when both flags are off.
- Sever legacy URL cascade: interview/roleplay/resume/courses Redis URLs
  resolve ONLY from their own explicit env vars, never from REDIS_URL or
  GROW_EVENTS_REDIS_URL.
- Export resolveRedisConfig() pure resolver for testability.
- Fix onboarding signal ID from 'onboarding.completed_baseline' to
  'onboarding.completed' (registry-valid in all v2 formula JSONs).
- Ensure interview/roleplay completed events emit at least a single
  completion signal when session_count/scenario_count is absent (default 1).
- Extract computeStreakFromDays() into streak-utils.ts (pure, no DB dep)
  with defensive dedup so duplicate days never inflate streaks.
- Add focused tests: redis-gating, signal-registry membership, count
  fallback, streak policy (current/longest/duplicate/gap/recovery/7-day),
  service-ingest projector behavior.
- typecheck: 80 pre-existing errors unchanged, 0 new.
This commit is contained in:
-Puter
2026-07-10 20:12:13 +05:30
parent d3a4e63e4a
commit ac7bdca912
16 changed files with 7330 additions and 102 deletions
+4 -4
View File
@@ -39,10 +39,10 @@ Use the Q Score service when the user wants to:
Default launch signals to ingest when detailed service results are not yet available:
- `resume.uploaded`
- `resume.ats_compatibility`
- `interview.completed`
- `roleplay.completed`
- `goal.clarity`
- `profile.role_fit`
- `interview.sessions_completed`
- `roleplay.scenarios_completed`
- `onboarding.goals_clarity`
- `onboarding.profile_completeness`
When richer product outputs are available, prefer real scores, completion states, assignment outcomes, review feedback, timestamps, and target-role context over generic defaults.
+6394
View File
File diff suppressed because it is too large Load Diff
+68
View File
@@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import { extractQscoreSignals } from "../src/events/projectors/qscore-projector.js";
import type { GrowEventRow } from "../src/db/schema.js";
/**
* Test: Completed interview and roleplay events MUST emit at least one signal
* even when session_count/scenario_count is absent. A completion event with no
* count and no rubric data should still produce a single-completion signal.
*/
function event(overrides: Partial<GrowEventRow> & { type: string; source: string; payload: Record<string, unknown> }): GrowEventRow {
return {
id: `event-${overrides.type}`,
userId: "user_test",
orgId: null,
source: overrides.source,
type: overrides.type,
category: "service",
occurredAt: new Date("2026-07-09T00:00:00.000Z"),
receivedAt: new Date("2026-07-09T00:00:01.000Z"),
mission: overrides.mission ?? null,
subject: overrides.subject ?? null,
correlation: overrides.correlation ?? null,
payload: overrides.payload,
raw: {},
dedupeKey: null,
processingStatus: "pending",
processingError: null,
processedAt: null,
};
}
// ── Interview completed with NO count and NO rubric ──────────────────────────
const interviewSignals = extractQscoreSignals(event({
source: "interview-service",
type: "interview.session.completed",
payload: {}, // nothing — just a bare completion event
}));
assert.ok(
interviewSignals.length > 0,
"Interview completed event with no count/rubric must emit at least one signal",
);
assert.ok(
interviewSignals.some((s) => s.signalId === "interview.sessions_completed" && s.score > 0),
"Bare interview completion should emit interview.sessions_completed with positive score",
);
// ── Roleplay completed with NO count and NO rubric ───────────────────────────
const roleplaySignals = extractQscoreSignals(event({
source: "roleplay-service",
type: "roleplay.scenario.completed",
payload: {}, // nothing — just a bare completion event
}));
assert.ok(
roleplaySignals.length > 0,
"Roleplay completed event with no count/rubric must emit at least one signal",
);
assert.ok(
roleplaySignals.some((s) => s.signalId === "roleplay.scenarios_completed" && s.score > 0),
"Bare roleplay completion should emit roleplay.scenarios_completed with positive score",
);
console.log("count-fallback tests passed");
process.exit(0);
+6 -8
View File
@@ -72,8 +72,8 @@ const generatedSignals = extractQscoreSignals(event({
},
}));
assert.ok(
generatedSignals.some((signal) => signal.signalId === "market.matches" && signal.score > 0),
"generated matches should produce a market opportunity signal from match counts",
generatedSignals.some((signal) => signal.signalId === "matching.match_rate" && signal.score > 0),
"generated matches should produce a match rate signal from match counts",
);
const savedSignals = extractQscoreSignals(event({
@@ -85,10 +85,7 @@ const savedSignals = extractQscoreSignals(event({
result: { status: "completed" },
},
}));
assert.ok(
savedSignals.some((signal) => signal.signalId === "networking.opportunities" && signal.score > 0),
"saved opportunities should produce an opportunity engagement signal without numeric match score",
);
assert.equal(savedSignals.length, 0, "saved opportunities no longer emit a legacy signal");
const reviewed = buildMatchmakingGatewayEvent({
userId: "user_test",
@@ -107,12 +104,13 @@ const appliedSignals = extractQscoreSignals(event({
action: "mark_applied",
opportunityId: "job_123",
curatorTaskId: "task_123",
applications_submitted: 1,
result: { status: "completed" },
},
}));
assert.ok(
appliedSignals.some((signal) => signal.signalId === "networking.opportunities" && signal.score >= 80),
"applied opportunities should produce a stronger opportunity signal without numeric match score",
appliedSignals.some((signal) => signal.signalId === "matching.applications_submitted" && signal.score > 0),
"applied opportunities should produce an applications submitted signal",
);
console.log("matchmaking-events tests passed");
+112
View File
@@ -0,0 +1,112 @@
import assert from "node:assert/strict";
import { resolveRedisConfig } from "../src/config.js";
/**
* Test: Redis ingestion consumers are default-off and do not load the Redis
* module unless an explicit opt-in flag is set. Legacy URLs must not inherit
* from the generic REDIS_URL.
*
* Uses the pure resolveRedisConfig resolver with synthetic env objects — no
* module-cache hacks needed.
*/
// ── 1. Default-off: no Redis flags set ──────────────────────────────────────
{
const rc = resolveRedisConfig({});
assert.equal(
rc.growEventsRedisEnabled,
false,
"GROW_EVENTS_REDIS_ENABLED must default to false",
);
assert.equal(
rc.legacyServiceRedisEnabled,
false,
"LEGACY_SERVICE_REDIS_ENABLED must default to false",
);
}
// ── 2. Legacy URLs must NOT inherit from generic REDIS_URL ──────────────────
{
// Set ONLY the generic REDIS_URL — legacy URLs must not pick it up.
const rc = resolveRedisConfig({ REDIS_URL: "redis://legacy-generic:6379" });
assert.equal(
rc.interviewRedisUrl,
"",
"interviewRedisUrl must NOT inherit from generic REDIS_URL",
);
assert.equal(
rc.roleplayRedisUrl,
"",
"roleplayRedisUrl must NOT inherit from generic REDIS_URL",
);
assert.equal(
rc.resumeRedisUrl,
"",
"resumeRedisUrl must NOT inherit from generic REDIS_URL",
);
assert.equal(
rc.coursesRedisUrl,
"",
"coursesRedisUrl must NOT inherit from generic REDIS_URL",
);
}
// ── 3. Legacy URLs must NOT inherit from GROW_EVENTS_REDIS_URL ───────────────
{
const rc = resolveRedisConfig({ GROW_EVENTS_REDIS_URL: "redis://canonical:6379" });
assert.equal(
rc.interviewRedisUrl,
"",
"interviewRedisUrl must NOT inherit from GROW_EVENTS_REDIS_URL",
);
assert.equal(rc.growEventsRedisUrl, "redis://canonical:6379", "canonical URL resolves from its own var");
}
// ── 4. Explicit opt-in flags are respected ──────────────────────────────────
{
const rc = resolveRedisConfig({ GROW_EVENTS_REDIS_ENABLED: "true" });
assert.equal(
rc.growEventsRedisEnabled,
true,
"GROW_EVENTS_REDIS_ENABLED=true must be respected",
);
assert.equal(
rc.legacyServiceRedisEnabled,
false,
"legacy must stay off when only canonical flag is set",
);
}
// ── 5. Legacy opt-in flag works independently ───────────────────────────────
{
const rc = resolveRedisConfig({
LEGACY_SERVICE_REDIS_ENABLED: "true",
INTERVIEW_REDIS_URL: "redis://interview:6379",
});
assert.equal(rc.legacyServiceRedisEnabled, true, "LEGACY_SERVICE_REDIS_ENABLED=true must be respected");
assert.equal(rc.growEventsRedisEnabled, false, "canonical must stay off when only legacy flag is set");
assert.equal(rc.interviewRedisUrl, "redis://interview:6379", "explicit interview URL resolves");
}
// ── 6. Explicit legacy URLs resolve from their own vars ──────────────────────
{
const rc = resolveRedisConfig({
INTERVIEW_REDIS_URL: "redis://i:6379",
ROLEPLAY_REDIS_URL: "redis://r:6379",
RESUME_REDIS_URL: "redis://re:6379",
COURSES_REDIS_URL: "redis://c:6379",
});
assert.equal(rc.interviewRedisUrl, "redis://i:6379");
assert.equal(rc.roleplayRedisUrl, "redis://r:6379");
assert.equal(rc.resumeRedisUrl, "redis://re:6379");
assert.equal(rc.coursesRedisUrl, "redis://c:6379");
}
console.log("redis-gating tests passed");
process.exit(0);
+106
View File
@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import { extractQscoreSignals } from "../src/events/projectors/qscore-projector.js";
import type { GrowEventRow } from "../src/db/schema.js";
/**
* Test: Service-ingest projector behavior across multiple service sources.
* Verifies that canonical Grow events from different services produce
* registry-valid signals with correct source attribution.
*/
function event(overrides: Partial<GrowEventRow> & { type: string; source: string; payload: Record<string, unknown> }): GrowEventRow {
return {
id: `event-${overrides.type}`,
userId: "user_test",
orgId: null,
source: overrides.source,
type: overrides.type,
category: "service",
occurredAt: new Date("2026-07-09T00:00:00.000Z"),
receivedAt: new Date("2026-07-09T00:00:01.000Z"),
mission: overrides.mission ?? null,
subject: overrides.subject ?? null,
correlation: overrides.correlation ?? null,
payload: overrides.payload,
raw: {},
dedupeKey: null,
processingStatus: "pending",
processingError: null,
processedAt: null,
};
}
// ── 1. Resume analysis produces resume signals ──────────────────────────────
{
const signals = extractQscoreSignals(event({
source: "resume-builder",
type: "resume.analysis.completed",
payload: { analysis: { score_breakdown: [{ category: "ATS Compatibility", score: 75 }] } },
}));
assert.ok(signals.length > 0, "resume analysis event should produce signals");
assert.ok(
signals.some((s) => s.signalId.startsWith("resume.")),
"resume source should produce resume.* signals",
);
}
// ── 2. Generic scored service event (qscore source) produces completion score ─
{
const signals = extractQscoreSignals(event({
source: "qscore-service",
type: "qscore.signal.projected",
payload: { score: 65 },
}));
assert.ok(
signals.some((s) => s.score === 65),
"qscore source event with score should forward the score",
);
}
// ── 3. Social branding pre-computed signals forwarded as-is ─────────────────
{
const signals = extractQscoreSignals(event({
source: "social-branding-service",
type: "brand.profile.updated",
payload: {
qscore_signals: [
{ signalId: "linkedin.headline_quality", score: 70 },
{ signalId: "linkedin.summary_complete", score: 65 },
],
},
}));
assert.ok(
signals.some((s) => s.signalId === "linkedin.headline_quality" && s.score === 70),
"social branding should forward pre-computed signals as-is",
);
assert.ok(
signals.some((s) => s.signalId === "linkedin.summary_complete"),
"social branding should forward all pre-computed signals",
);
}
// ── 4. Unknown source with no score produces no signals ──────────────────────
{
const signals = extractQscoreSignals(event({
source: "unknown-service",
type: "service.completed",
payload: {},
}));
assert.equal(signals.length, 0, "unknown source with no score should produce no signals");
}
// ── 5. Matchmaking applied event with count ──────────────────────────────────
{
const signals = extractQscoreSignals(event({
source: "matchmaking-v2",
type: "matchmaking.match.applied",
payload: { applications_submitted: 3 },
}));
assert.ok(
signals.some((s) => s.signalId === "matching.applications_submitted" && s.score > 0),
"matchmaking applied with count should produce applications_submitted signal",
);
}
console.log("service-ingest-projector tests passed");
process.exit(0);
+238
View File
@@ -0,0 +1,238 @@
import assert from "node:assert/strict";
import { extractQscoreSignals } from "../src/events/projectors/qscore-projector.js";
import { ONBOARDING_BASELINE_SIGNAL_ID } from "../src/events/onboarding-qscore.js";
import type { GrowEventRow } from "../src/db/schema.js";
/**
* Test: All signal IDs emitted by the projector must be valid members of the
* qscore_service v2 signal registry. The onboarding baseline signal must be
* "onboarding.completed" (not the non-registry "onboarding.completed_baseline").
*/
// v2 registry — union of all signal_ids across all 7 profession formula JSONs.
// Sourced from qscore_service/app/scoring/formula/v2/*/formula.json
const V2_REGISTRY = new Set<string>([
// resume
"resume.uploaded",
"resume.ats_compatibility",
"resume.keyword_relevance",
"resume.quantified_achievements",
"resume.grammar_clarity",
"resume.format_structure",
"resume.contact_info",
"resume.page_count",
// interview
"interview.sessions_completed",
"interview.overall_score",
"interview.response_clarity",
"interview.technical_accuracy",
"interview.behavioral_quality",
"interview.improvement_over_time",
"interview.type_diversity",
// roleplay
"roleplay.scenarios_completed",
"roleplay.situational_judgment",
"roleplay.empathy_demonstrated",
"roleplay.problem_resolution",
"roleplay.communication_effectiveness",
// courses
"courses.started",
"courses.completed",
"courses.completion_rate",
"courses.difficulty",
"courses.pathway_relevance",
// matching
"matching.jobs_viewed",
"matching.applications_submitted",
"matching.application_quality",
"matching.match_rate",
// onboarding
"onboarding.completed",
"onboarding.initial_skills_score",
"onboarding.goals_clarity",
"onboarding.profile_completeness",
"onboarding.self_assessment_accuracy",
"onboarding.motivation_quality",
// coverletter
"coverletter.uploaded",
"coverletter.customization",
"coverletter.persuasiveness",
// linkedin
"linkedin.account_connected",
"linkedin.profile_photo",
"linkedin.headline_quality",
"linkedin.summary_complete",
"linkedin.experience_detail",
"linkedin.skills_listed",
// portfolio
"portfolio.website_exists",
"portfolio.website_quality",
"portfolio.content_relevance",
// content
"content.articles_count",
"content.quality",
"content.platform_credibility",
// ugc
"ugc.speaking_count",
"ugc.event_credibility",
"ugc.verifiable_evidence",
// assessments
"assessments.taken",
"assessments.passed",
"assessments.avg_score",
"assessments.skill_diversity",
// presentations
"presentations.submitted",
"presentations.content_quality",
"presentations.delivery_score",
"presentations.visual_aids",
// events
"events.webinars_attended",
"events.meetups_attended",
"events.participation_quality",
// mentor
"mentor.feedback_count",
"mentor.score",
"mentor.implementation_rate",
]);
function event(overrides: Partial<GrowEventRow> & { type: string; payload: Record<string, unknown>; source: string }): GrowEventRow {
return {
id: `event-${overrides.type}`,
userId: "user_test",
orgId: null,
source: overrides.source,
type: overrides.type,
category: "service",
occurredAt: new Date("2026-07-09T00:00:00.000Z"),
receivedAt: new Date("2026-07-09T00:00:01.000Z"),
mission: overrides.mission ?? null,
subject: overrides.subject ?? null,
correlation: overrides.correlation ?? null,
payload: overrides.payload,
raw: {},
dedupeKey: null,
processingStatus: "pending",
processingError: null,
processedAt: null,
};
}
// ── 1. Onboarding signal ID must be the registry-valid "onboarding.completed" ─
assert.equal(
ONBOARDING_BASELINE_SIGNAL_ID,
"onboarding.completed",
"Onboarding baseline signal ID must be 'onboarding.completed' (registry-valid), not 'onboarding.completed_baseline'",
);
assert.ok(
V2_REGISTRY.has(ONBOARDING_BASELINE_SIGNAL_ID),
"Onboarding signal ID must appear in v2 registry",
);
// ── 2. All projector signal IDs must be in the registry ──────────────────────
// Construct representative events for each source family and check every emitted
// signal ID is registry-valid.
// Resume with full breakdown
const resumeSignals = extractQscoreSignals(event({
source: "resume-builder",
type: "resume.analysis.completed",
payload: {
analysis: {
score_breakdown: [
{ category: "ATS Compatibility", score: 70 },
{ category: "Content Quality", score: 65 },
{ category: "Formatting", score: 80 },
],
dimensional_scores: [
{ dimension: "Keywords", score: 60 },
{ dimension: "Quantification", score: 55 },
],
},
},
}));
for (const s of resumeSignals) {
assert.ok(
V2_REGISTRY.has(s.signalId),
`Resume signal '${s.signalId}' is not in the v2 registry`,
);
}
// Interview with rubric
const interviewSignals = extractQscoreSignals(event({
source: "interview-service",
type: "interview.session.completed",
payload: {
review: {
overall_score: 72,
rubric_scores: { content_quality: 70, role_alignment: 68, language: 65 },
historical_comparison: { overall_delta: 5 },
},
session_count: 3,
type_diversity: 2,
},
}));
for (const s of interviewSignals) {
assert.ok(
V2_REGISTRY.has(s.signalId),
`Interview signal '${s.signalId}' is not in the v2 registry`,
);
}
// Roleplay with rubric
const roleplaySignals = extractQscoreSignals(event({
source: "roleplay-service",
type: "roleplay.scenario.completed",
payload: {
review: {
rubric_scores: {
scenario_adherence: 75,
emotional_intelligence: 70,
adaptability: 68,
content: 72,
},
},
scenario_count: 4,
},
}));
for (const s of roleplaySignals) {
assert.ok(
V2_REGISTRY.has(s.signalId),
`Roleplay signal '${s.signalId}' is not in the v2 registry`,
);
}
// Courses
const courseSignals = extractQscoreSignals(event({
source: "courses-service",
type: "course.completed",
payload: {
courseId: "c1",
completed_count: 2,
watchPct: 0.9,
difficulty: "intermediate",
label: "high relevance",
},
}));
for (const s of courseSignals) {
assert.ok(
V2_REGISTRY.has(s.signalId),
`Course signal '${s.signalId}' is not in the v2 registry`,
);
}
// Matchmaking
const matchSignals = extractQscoreSignals(event({
source: "matchmaking-v2",
type: "matchmaking.feed.viewed",
payload: { jobs_viewed: 25 },
}));
for (const s of matchSignals) {
assert.ok(
V2_REGISTRY.has(s.signalId),
`Matchmaking signal '${s.signalId}' is not in the v2 registry`,
);
}
console.log("signal-registry tests passed");
process.exit(0);
+95
View File
@@ -0,0 +1,95 @@
import assert from "node:assert/strict";
import { computeStreakFromDays } from "../src/v1/curator/streak-utils.js";
/**
* Test: Streak policies — current/longest, same-day duplicate, gap reset,
* recovery (resume after gap), and seven-day eligibility.
*
* Uses the pure computeStreakFromDays function with a fixed "today" so tests
* are deterministic.
*/
const TODAY = "2026-07-10";
// ── 1. Current and longest from a contiguous run ending today ────────────────
{
const days = ["2026-07-10", "2026-07-09", "2026-07-08"];
const streak = computeStreakFromDays(days, TODAY);
assert.equal(streak.current, 3, "current streak should be 3 for 3 consecutive days ending today");
assert.equal(streak.longest, 3, "longest should match current when run is unbroken");
assert.equal(streak.lastCompletedDate, "2026-07-10");
}
// ── 2. Same-day duplicate must not inflate the streak ────────────────────────
// computeStreakFromDays requires deduplicated days (the SQL GROUP BY ensures
// this in production). Passing a duplicate should not double-count.
{
const days = ["2026-07-10", "2026-07-10", "2026-07-09"];
const streak = computeStreakFromDays(days, TODAY);
// With a duplicate, the descending list has two "today" entries. The while
// loop checks `days.includes(cursor)` which is set-based, so current stays 2.
// But the longest loop iterates ALL entries including the dup, which could
// inflate longest to 3. The correct behavior: duplicates must not inflate.
assert.equal(streak.current, 2, "duplicate day must not inflate current streak");
assert.equal(
streak.longest,
2,
"duplicate day must not inflate longest streak — duplicates should be deduped before counting",
);
}
// ── 3. Gap resets the current streak ─────────────────────────────────────────
{
// Completed today and 3 days ago — gap breaks current streak
const days = ["2026-07-10", "2026-07-07"];
const streak = computeStreakFromDays(days, TODAY);
assert.equal(streak.current, 1, "gap resets current streak to 1 (only today)");
assert.equal(streak.longest, 1, "longest should also be 1 with only isolated days");
}
// ── 4. Recovery: longest captures a past longer run even if current is broken ─
{
// 5-day run last week, then a gap, then completed today
const days = [
"2026-07-10", // today (current restart)
"2026-07-05", // past run: Jul 1–5
"2026-07-04",
"2026-07-03",
"2026-07-02",
"2026-07-01",
];
const streak = computeStreakFromDays(days, TODAY);
assert.equal(streak.current, 1, "current is 1 after a gap even if a longer past run exists");
assert.equal(streak.longest, 5, "longest should capture the 5-day past run");
}
// ── 5. Seven-day eligibility: a 7-day streak counts as eligible ──────────────
{
const days = [
"2026-07-10", "2026-07-09", "2026-07-08",
"2026-07-07", "2026-07-06", "2026-07-05", "2026-07-04",
];
const streak = computeStreakFromDays(days, TODAY);
assert.equal(streak.current, 7, "7 consecutive days should yield current=7");
assert.equal(streak.longest, 7, "7 consecutive days should yield longest=7");
assert.ok(streak.current >= 7, "7-day streak eligibility threshold met");
}
// ── 6. Empty days yields zero streak ─────────────────────────────────────────
{
const streak = computeStreakFromDays([], TODAY);
assert.equal(streak.current, 0);
assert.equal(streak.longest, 0);
assert.equal(streak.lastCompletedDate, null);
}
// ── 7. Not completed today but completed yesterday: current is 0 ─────────────
{
const days = ["2026-07-09"];
const streak = computeStreakFromDays(days, TODAY);
assert.equal(streak.current, 0, "current is 0 if today is not completed");
assert.equal(streak.longest, 1, "longest should still count the 1-day run");
}
console.log("streak-policy tests passed");
process.exit(0);
+48 -6
View File
@@ -7,7 +7,41 @@ function required(name: string, fallback?: string): string {
}
return v;
}
function parseBool(value: string | undefined, fallback: boolean): boolean {
if (value === undefined || value === "") return fallback;
return value.toLowerCase() === "true" || value === "1";
}
// ── Redis config resolver (pure, testable without module reload) ────────────
// Exported so tests can assert gating/URL-inheritance behavior with synthetic
// env objects, avoiding ESM module-cache hacks.
export type RedisConfig = {
growEventsRedisEnabled: boolean;
legacyServiceRedisEnabled: boolean;
growEventsRedisUrl: string;
interviewRedisUrl: string;
roleplayRedisUrl: string;
resumeRedisUrl: string;
coursesRedisUrl: string;
};
/**
* Resolve all Redis-related config from an env record. Both ingestion paths
* are default-off; legacy URLs resolve ONLY from their own explicit env var
* (never inherited from GROW_EVENTS_REDIS_URL or the generic REDIS_URL).
*/
export function resolveRedisConfig(env: Record<string, string | undefined>): RedisConfig {
return {
growEventsRedisEnabled: parseBool(env.GROW_EVENTS_REDIS_ENABLED, false),
legacyServiceRedisEnabled: parseBool(env.LEGACY_SERVICE_REDIS_ENABLED, false),
growEventsRedisUrl: env.GROW_EVENTS_REDIS_URL ?? "",
interviewRedisUrl: env.INTERVIEW_REDIS_URL ?? "",
roleplayRedisUrl: env.ROLEPLAY_REDIS_URL ?? "",
resumeRedisUrl: env.RESUME_REDIS_URL ?? "",
coursesRedisUrl: env.COURSES_REDIS_URL ?? env.COURSE_REDIS_URL ?? "",
};
}
export const config = {
port: Number(process.env.PORT ?? 4000),
logLevel: process.env.LOG_LEVEL ?? "info",
@@ -26,17 +60,24 @@ export const config = {
a2aAllowedKey: process.env.A2A_ALLOWED_KEY ?? "dev-a2a-key",
// Service → backend event stream. Redis is optional; HTTP /events/ingest/service is always available.
growEventsRedisUrl: process.env.GROW_EVENTS_REDIS_URL ?? process.env.REDIS_URL ?? "",
// Explicit opt-in flags: both Redis ingestion paths are default-off. The REST
// production path never touches Redis. Each flag is independent so operators
// can enable canonical and/or legacy observers separately.
growEventsRedisEnabled: parseBool(process.env.GROW_EVENTS_REDIS_ENABLED, false),
legacyServiceRedisEnabled: parseBool(process.env.LEGACY_SERVICE_REDIS_ENABLED, false),
growEventsRedisUrl: process.env.GROW_EVENTS_REDIS_URL ?? "",
growEventsStream: process.env.GROW_EVENTS_STREAM ?? "grow.events.raw",
growEventsConsumerGroup: process.env.GROW_EVENTS_CONSUMER_GROUP ?? "growqr-backend",
growEventsConsumerName: process.env.GROW_EVENTS_CONSUMER_NAME ?? `backend-${process.pid}`,
// Legacy service Redis surfaces. These let backend observe existing service A2A traffic
// without changing the services. Defaults fall back to GROW_EVENTS_REDIS_URL/REDIS_URL.
interviewRedisUrl: process.env.INTERVIEW_REDIS_URL ?? process.env.GROW_EVENTS_REDIS_URL ?? process.env.REDIS_URL ?? "",
roleplayRedisUrl: process.env.ROLEPLAY_REDIS_URL ?? process.env.GROW_EVENTS_REDIS_URL ?? process.env.REDIS_URL ?? "",
resumeRedisUrl: process.env.RESUME_REDIS_URL ?? process.env.GROW_EVENTS_REDIS_URL ?? process.env.REDIS_URL ?? "",
coursesRedisUrl: process.env.COURSES_REDIS_URL ?? process.env.COURSE_REDIS_URL ?? process.env.GROW_EVENTS_REDIS_URL ?? process.env.REDIS_URL ?? "",
// without changing the services. Legacy URLs resolve ONLY from their own explicit env
// var — they must not inherit from GROW_EVENTS_REDIS_URL or the generic REDIS_URL, so a
// stray REDIS_URL can never silently activate the legacy observer path.
interviewRedisUrl: process.env.INTERVIEW_REDIS_URL ?? "",
roleplayRedisUrl: process.env.ROLEPLAY_REDIS_URL ?? "",
resumeRedisUrl: process.env.RESUME_REDIS_URL ?? "",
coursesRedisUrl: process.env.COURSES_REDIS_URL ?? process.env.COURSE_REDIS_URL ?? "",
legacyServiceTaskObserverGroup: process.env.LEGACY_SERVICE_TASK_OBSERVER_GROUP ?? "growqr-backend-observer",
// LLM gateway for the unified user agent.
@@ -156,3 +197,4 @@ export const config = {
required, // exported so other modules can fail fast on boot
} as const;
+1 -1
View File
@@ -1,7 +1,7 @@
import { forwardSignalsToQscoreService } from "../services/service-agents.js";
import { DEFAULT_QSCORE_ORG_ID } from "../services/qscore-proxy.js";
export const ONBOARDING_BASELINE_SIGNAL_ID = "onboarding.completed_baseline";
export const ONBOARDING_BASELINE_SIGNAL_ID = "onboarding.completed";
/**
* Workbook threshold for a completed onboarding baseline signal
+166 -43
View File
@@ -1,5 +1,5 @@
import { type GrowEventRow } from "../../db/schema.js";
import { asRecord, clampScore, getNumber, type QscoreSignal } from "../envelope.js";
import { asRecord, clampScore, getNumber, getString, type QscoreSignal } from "../envelope.js";
import { forwardSignalsToQscoreService } from "../../services/service-agents.js";
import { DEFAULT_QSCORE_ORG_ID } from "../../services/qscore-proxy.js";
@@ -51,7 +51,6 @@ function extractResumeSignals(event: GrowEventRow): QscoreSignal[] {
const keywords = byDimension.get("Keywords") ?? nestedNumber(analysis, ["keyword_score", "keyword_relevance", "keywords"]);
if (keywords !== undefined) {
signals.push(signal("resume.keyword_relevance", keywords, { eventId: event.id }));
signals.push(signal("resume.technical_keywords", keywords, { eventId: event.id }));
}
const quantification = byDimension.get("Quantification") ?? nestedNumber(analysis, ["quantification_score"]);
if (quantification !== undefined) signals.push(signal("resume.quantified_achievements", quantification, { eventId: event.id }));
@@ -59,14 +58,29 @@ function extractResumeSignals(event: GrowEventRow): QscoreSignal[] {
if (contentQuality !== undefined) signals.push(signal("resume.grammar_clarity", contentQuality, { eventId: event.id }));
const formatting = byCategory.get("Formatting") ?? nestedNumber(analysis, ["formatting", "format_score"]);
if (formatting !== undefined) signals.push(signal("resume.format_structure", formatting, { eventId: event.id }));
const impact = byCategory.get("Impact Demonstration") ?? nestedNumber(analysis, ["impact_score"]);
if (impact !== undefined) {
signals.push(signal("resume.leadership_indicators", impact, { eventId: event.id }));
signals.push(signal("resume.impact_statements", impact, { eventId: event.id }));
}
return signals;
}
function interviewSessionsScore(count: number): number {
if (count <= 0) return 0;
if (count <= 2) return 20;
if (count <= 5) return 35;
return 50;
}
function interviewDiversityScore(count: number): number {
if (count <= 1) return 10;
if (count === 2) return 20;
return 30;
}
function roleplayScenariosScore(count: number): number {
if (count <= 0) return 0;
if (count <= 3) return 20;
if (count <= 6) return 35;
return 50;
}
function extractInterviewSignals(event: GrowEventRow): QscoreSignal[] {
const payload = event.payload ?? {};
const review = asRecord(payload.review ?? payload.result ?? payload);
@@ -74,7 +88,14 @@ function extractInterviewSignals(event: GrowEventRow): QscoreSignal[] {
if (!event.type.includes("review") && !event.type.includes("completed") && status !== "completed") return [];
const signals: QscoreSignal[] = [];
signals.push(signal("interview.completed", 100, { eventId: event.id }));
// A completed interview must always emit at least a single-completion signal.
// When session_count is absent, default to 1 so the event is not silently lost.
const sessionCount = getNumber(payload.session_count ?? payload.sessionCount) ?? 1;
signals.push(signal("interview.sessions_completed", interviewSessionsScore(sessionCount), { eventId: event.id, sessionCount }));
const typeDiversity = getNumber(payload.type_diversity ?? payload.typeDiversity);
if (typeDiversity !== undefined) {
signals.push(signal("interview.type_diversity", interviewDiversityScore(typeDiversity), { eventId: event.id, typeDiversity }));
}
const overall = getNumber(review.overall_score ?? review.overallScore ?? payload.overall_score);
if (overall !== undefined) signals.push(signal("interview.overall_score", overall, { eventId: event.id }));
const rubric = asRecord(review.rubric_scores ?? review.rubricScores);
@@ -97,7 +118,10 @@ function extractRoleplaySignals(event: GrowEventRow): QscoreSignal[] {
if (!event.type.includes("review") && !event.type.includes("completed") && status !== "completed") return [];
const signals: QscoreSignal[] = [];
signals.push(signal("roleplay.completed", 100, { eventId: event.id }));
// A completed roleplay must always emit at least a single-completion signal.
// When scenario_count is absent, default to 1 so the event is not silently lost.
const scenarioCount = getNumber(payload.scenario_count ?? payload.scenarioCount) ?? 1;
signals.push(signal("roleplay.scenarios_completed", roleplayScenariosScore(scenarioCount), { eventId: event.id, scenarioCount }));
const rubric = asRecord(review.rubric_scores ?? review.rubricScores);
const scenario = getNumber(rubric.scenario_adherence ?? review.scenario_adherence_score);
if (scenario !== undefined) signals.push(signal("roleplay.situational_judgment", scenario, { eventId: event.id }));
@@ -107,32 +131,76 @@ function extractRoleplaySignals(event: GrowEventRow): QscoreSignal[] {
if (adaptability !== undefined) signals.push(signal("roleplay.problem_resolution", adaptability, { eventId: event.id }));
const communication = getNumber(rubric.content ?? rubric.communication ?? review.communication_score);
if (communication !== undefined) signals.push(signal("roleplay.communication_effectiveness", communication, { eventId: event.id }));
const historical = asRecord(review.historical_comparison ?? review.historicalComparison);
const delta = getNumber(historical.overall_delta ?? historical.overallDelta);
if (delta !== undefined) signals.push(signal("roleplay.improvement_over_time", 50 + delta * 2.5, { eventId: event.id, delta }));
return signals;
}
function coursesCompletedScore(count: number): number {
if (count <= 0) return 0;
if (count <= 3) return 35;
if (count <= 10) return 60;
return 80;
}
function courseCompletionRateScore(pct: number): number {
if (pct < 50) return 20;
if (pct <= 80) return 40;
return 60;
}
function courseDifficultyScore(difficulty: string): number | undefined {
const d = difficulty.toLowerCase();
if (d.includes("beginner") || d === "easy") return 20;
if (d.includes("inter") || d.includes("medium")) return 35;
if (d.includes("adv")) return 50;
return undefined;
}
function coursePathwayRelevanceScore(label: string): number | undefined {
const l = label.toLowerCase();
if (l.includes("high")) return 40;
if (l.includes("med")) return 25;
if (l.includes("low")) return 10;
return undefined;
}
function extractCourseSignals(event: GrowEventRow): QscoreSignal[] {
const payload = event.payload ?? {};
if (!event.type.includes("progress_recorded") && !event.type.includes("completed")) return [];
if (
!event.type.includes("progress_recorded") &&
!event.type.includes("completed") &&
!event.type.includes("started") &&
!event.type.includes("watch")
)
return [];
const watchPct = getNumber(payload.watchPct ?? payload.watch_pct);
const progressPercentage = getNumber(payload.progressPercentage ?? payload.progress_percentage);
const progressScore = progressPercentage ?? (watchPct === undefined ? undefined : watchPct * 100);
const score = clampScore(Math.max(35, Math.min(100, progressScore ?? 50)));
const watchPctRaw = getNumber(payload.watchPct ?? payload.watch_pct) ?? 0;
const watchPctScaled = watchPctRaw <= 1 ? watchPctRaw * 100 : watchPctRaw;
const completedCount = getNumber(payload.completed_count ?? payload.completedCount ?? payload.courses_completed);
const difficulty = getString(payload.difficulty);
const label = getString(payload.label ?? payload.pathway_label);
const raw = {
eventId: event.id,
taskId: payload.taskId ?? payload.curatorTaskId ?? event.correlation?.taskId,
courseId: payload.courseId ?? payload.course_id ?? event.correlation?.courseId,
lessonId: payload.lessonId ?? payload.lesson_id ?? event.correlation?.lessonId,
watchPct,
courseId: payload.courseId ?? payload.course_id,
lessonId: payload.lessonId ?? payload.lesson_id,
watchPct: watchPctRaw,
};
return [
signal("skills.learning", score, raw),
signal("readiness.course_progress", score, { ...raw, source: event.source, type: event.type }),
];
const signals: QscoreSignal[] = [];
signals.push(signal("courses.started", 40, raw));
signals.push(signal("courses.completion_rate", courseCompletionRateScore(watchPctScaled), { ...raw, completionPct: watchPctScaled }));
if (watchPctScaled >= 80) {
signals.push(signal("courses.completed", coursesCompletedScore(completedCount ?? 1), { ...raw, completedCount: completedCount ?? 1 }));
}
if (difficulty) {
const diffScore = courseDifficultyScore(difficulty);
if (diffScore !== undefined) signals.push(signal("courses.difficulty", diffScore, { ...raw, difficulty }));
}
if (label) {
const relScore = coursePathwayRelevanceScore(label);
if (relScore !== undefined) signals.push(signal("courses.pathway_relevance", relScore, { ...raw, label }));
}
return signals;
}
function sourceSignalPrefix(source: string) {
@@ -191,24 +259,31 @@ function countFromPayload(payload: Record<string, unknown>): number | undefined
return requestOpportunities?.length;
}
function opportunityActionScore(event: GrowEventRow, count?: number): number | undefined {
if (event.type === "matchmaking.matches.generated") return clampScore(58 + Math.min(count ?? 0, 20));
if (event.type === "matchmaking.feed.viewed") return count && count > 0 ? 58 : 48;
if (event.type === "matchmaking.match.viewed") return 62;
if (event.type === "matchmaking.match.saved") return 72;
if (event.type === "matchmaking.matches.reviewed") return 76;
if (event.type === "matchmaking.match.applied") return 86;
if (event.type === "matchmaking.application.completed") return 90;
return undefined;
function jobsViewedScore(count: number): number {
if (count <= 0) return 0;
if (count <= 20) return 15;
if (count <= 50) return 30;
return 40;
}
function applicationsSubmittedScore(count: number): number {
if (count <= 0) return 0;
if (count <= 5) return 20;
if (count <= 15) return 35;
return 50;
}
function matchRateScore(pct: number): number {
if (pct < 50) return 15;
if (pct <= 75) return 35;
return 50;
}
function extractMatchmakingSignals(event: GrowEventRow): QscoreSignal[] {
if (!event.type.startsWith("matchmaking.")) return [];
const payload = event.payload ?? {};
const count = countFromPayload(payload);
const score = opportunityActionScore(event, count);
if (score === undefined) return [];
const raw = {
eventId: event.id,
type: event.type,
@@ -219,9 +294,59 @@ function extractMatchmakingSignals(event: GrowEventRow): QscoreSignal[] {
status: payload.status,
};
if (event.type === "matchmaking.matches.generated") return [signal("market.matches", score, raw)];
if (event.type === "matchmaking.feed.viewed") return [signal("market.opportunity_feed_viewed", score, raw)];
return [signal("networking.opportunities", score, raw)];
const signals: QscoreSignal[] = [];
// Jobs viewed — from feed.viewed and match.viewed events with cumulative counts
if (event.type === "matchmaking.feed.viewed" || event.type === "matchmaking.match.viewed") {
const viewedCount = getNumber(payload.jobs_viewed ?? payload.jobsViewed ?? payload.viewed_count ?? payload.viewedCount ?? count);
signals.push(signal("matching.jobs_viewed", jobsViewedScore(viewedCount ?? 1), { ...raw, viewedCount }));
}
// Applications submitted — from match.applied and application.completed events
if (event.type === "matchmaking.match.applied" || event.type === "matchmaking.application.completed") {
const appliedCount = getNumber(payload.applications_submitted ?? payload.applicationsSubmitted ?? payload.applied_count ?? payload.appliedCount ?? count);
signals.push(signal("matching.applications_submitted", applicationsSubmittedScore(appliedCount ?? 1), { ...raw, appliedCount }));
}
// Application quality — from match quality scores if available
const qualityScore = getNumber(payload.application_quality ?? payload.applicationQuality ?? payload.match_quality ?? payload.matchQuality);
if (qualityScore !== undefined) {
signals.push(signal("matching.application_quality", qualityScore, { ...raw, qualityScore }));
}
// Match rate — from matches.generated with match data
if (event.type === "matchmaking.matches.generated" && count !== undefined) {
const totalCandidates = getNumber(payload.total_candidates ?? payload.totalCandidates) ?? count;
const rate = totalCandidates > 0 ? (count / totalCandidates) * 100 : 0;
signals.push(signal("matching.match_rate", matchRateScore(rate), { ...raw, matchRate: rate }));
}
return signals;
}
function extractSocialSignals(event: GrowEventRow): QscoreSignal[] {
const payload = event.payload ?? {};
const signals: QscoreSignal[] = [];
// social-branding sends pre-computed qscore_signals array — forward as-is
const incoming = Array.isArray(payload.qscore_signals) ? payload.qscore_signals : Array.isArray(payload.qscoreSignals) ? payload.qscoreSignals : undefined;
if (incoming) {
for (const entry of incoming) {
const record = asRecord(entry);
const id = getString(record.signalId ?? record.signal_id ?? record.id);
const score = getNumber(record.score ?? record.value);
if (id !== undefined && score !== undefined) {
signals.push(signal(id, score, { eventId: event.id, source: event.source }));
}
}
return signals;
}
// Fall back to inline fields for backward compatibility
const inline = asRecord(payload.signals);
for (const [key, value] of Object.entries(inline)) {
const score = getNumber(value);
if (score !== undefined) signals.push(signal(key, score, { eventId: event.id, source: event.source }));
}
return signals;
}
export function extractQscoreSignals(event: GrowEventRow): QscoreSignal[] {
@@ -231,12 +356,10 @@ export function extractQscoreSignals(event: GrowEventRow): QscoreSignal[] {
if (source.includes("roleplay") || event.type.startsWith("roleplay.")) return extractRoleplaySignals(event);
if (source.includes("course") || event.type.startsWith("course.")) return extractCourseSignals(event);
if (source.includes("matchmaking") || event.type.startsWith("matchmaking.")) return extractMatchmakingSignals(event);
if (source.includes("social") || source.includes("branding") || source.includes("linkedin")) return extractSocialSignals(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 })];
}
return [];
}
+26 -7
View File
@@ -393,14 +393,33 @@ async function startCanonicalGrowEventStream(createClient: (opts: { url: string
}
export async function startGrowEventsRedisConsumer() {
const createClient = await loadRedisCreateClient();
await startCanonicalGrowEventStream(createClient);
const specs = serviceSpecs();
if (!specs.length) {
log.info("legacy service Redis observers disabled (INTERVIEW_REDIS_URL/ROLEPLAY_REDIS_URL/RESUME_REDIS_URL not set)");
// Both Redis ingestion paths are default-off. When neither flag is set, we
// return immediately WITHOUT calling loadRedisCreateClient() — so the
// `redis` module is never imported and no client is constructed. The REST
// production path (POST /events/ingest/service) handles all event ingestion
// without Redis.
if (!config.growEventsRedisEnabled && !config.legacyServiceRedisEnabled) {
log.info("grow events Redis consumers disabled (GROW_EVENTS_REDIS_ENABLED and LEGACY_SERVICE_REDIS_ENABLED both off)");
return;
}
await Promise.all(specs.map((spec) => startLegacyServiceObserver(spec, createClient)));
// At least one path is opted in — now it is safe to load the Redis module.
const createClient = await loadRedisCreateClient();
if (config.growEventsRedisEnabled) {
if (!config.growEventsRedisUrl) {
log.warn("GROW_EVENTS_REDIS_ENABLED=true but GROW_EVENTS_REDIS_URL not set — canonical consumer skipped");
} else {
await startCanonicalGrowEventStream(createClient);
}
}
if (config.legacyServiceRedisEnabled) {
const specs = serviceSpecs();
if (!specs.length) {
log.info("legacy service Redis observers enabled but no service URLs configured (INTERVIEW_REDIS_URL/ROLEPLAY_REDIS_URL/RESUME_REDIS_URL not set)");
} else {
await Promise.all(specs.map((spec) => startLegacyServiceObserver(spec, createClient)));
}
}
}
+2 -2
View File
@@ -89,10 +89,10 @@ export async function seedDemoHome(userId: string) {
["resume.uploaded", 100, "resume-service"],
["resume.ats_compatibility", 86, "resume-service"],
["resume.keyword_relevance", 84, "resume-service"],
["interview.completed", 100, "interview-service"],
["interview.sessions_completed", 35, "interview-service"],
["interview.overall_score", 82, "interview-service"],
["interview.response_clarity", 74, "interview-service"],
["roleplay.completed", 100, "roleplay-service"],
["roleplay.scenarios_completed", 35, "roleplay-service"],
["roleplay.communication_effectiveness", 79, "roleplay-service"],
] as const;
+4 -4
View File
@@ -563,7 +563,7 @@ const serviceRegistry: ServiceRecord[] = [
},
toolName: "prepare_course_handoff",
completionEvents: ["course.generated", "course.started", "course.progress_recorded", "course.lesson.completed", "course.completed", "course.weakness.applied"],
qscoreSignals: ["skills.learning", "readiness.course_progress"],
qscoreSignals: ["courses.started", "courses.completed", "courses.completion_rate"],
usage: "Use for skill-gap tasks that should become learning work.",
},
usageDocs: ["Call buildServiceLink('courses-service', 'catalog', state) for course handoffs."],
@@ -574,7 +574,7 @@ const serviceRegistry: ServiceRecord[] = [
configuredEvents: ["course.generated"],
completionEvents: ["service.completed", "course.progress_recorded", "course.lesson.completed", "course.completed", "course.weakness.applied"],
abandonmentEvents: ["service.abandoned"],
qscoreSignalIds: ["course.skill_gap_closed", "skills.learning", "readiness.course_progress"],
qscoreSignalIds: ["courses.started", "courses.completed", "courses.completion_rate", "courses.difficulty", "courses.pathway_relevance"],
analyticsDimensions: ["course_id", "lesson_id", "progress_percentage", "weakness_tags"],
requiredCorrelationFields: ["taskId"],
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
@@ -694,7 +694,7 @@ const serviceRegistry: ServiceRecord[] = [
"matchmaking.application.started",
"matchmaking.application.completed",
],
qscoreSignals: ["market.matches", "networking.opportunities"],
qscoreSignals: ["matching.jobs_viewed", "matching.applications_submitted", "matching.match_rate"],
usage: "Use for immediate opportunity matching or mentor/network suggestions.",
},
usageDocs: [
@@ -717,7 +717,7 @@ const serviceRegistry: ServiceRecord[] = [
"matchmaking.application.completed",
],
abandonmentEvents: ["service.abandoned"],
qscoreSignalIds: ["matchmaking.role_fit", "market.matches", "networking.opportunities"],
qscoreSignalIds: ["matching.jobs_viewed", "matching.applications_submitted", "matching.application_quality", "matching.match_rate"],
analyticsDimensions: ["match_count", "role_ids", "role_fit", "saved_count", "dismissed_reason"],
requiredCorrelationFields: ["taskId"],
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
+2 -27
View File
@@ -35,6 +35,7 @@ import {
} from "./task-registry.js";
import { kpiDefinitionsForIcp } from "./kpi-registry.js";
import { outcomeDefinitionsForIcp } from "./outcome-report-registry.js";
import { addDaysIso, computeStreakFromDays, todayIso } from "./streak-utils.js";
const VALID_COMPLETION_TYPES = [
"service.completed",
@@ -108,15 +109,6 @@ type ServiceCurationPreviewInput = {
userContext?: Partial<Awaited<ReturnType<typeof buildCuratorUserContext>>>;
};
function todayIso(date = new Date()) {
return date.toISOString().slice(0, 10);
}
function addDaysIso(startDate: string, days: number) {
const date = new Date(`${startDate}T00:00:00.000Z`);
date.setUTCDate(date.getUTCDate() + days);
return todayIso(date);
}
function calendarDiffDays(startDate: string, endDate: string) {
const start = new Date(`${startDate}T00:00:00.000Z`).getTime();
@@ -1009,24 +1001,7 @@ export async function buildCuratorStreak(userId: string): Promise<CuratorStreak>
.limit(90);
const days = rows.map((row) => row.day);
let current = 0;
let cursor = todayIso();
while (days.includes(cursor)) {
current += 1;
cursor = addDaysIso(cursor, -1);
}
let longest = 0;
let run = 0;
let previous: string | null = null;
for (const day of [...days].reverse()) {
if (previous && addDaysIso(previous, 1) !== day) run = 0;
run += 1;
longest = Math.max(longest, run);
previous = day;
}
return { current, longest: Math.max(longest, current), lastCompletedDate: days[0] ?? null };
return computeStreakFromDays(days);
}
type CuratorSprintBuildOptions = {
+58
View File
@@ -0,0 +1,58 @@
import type { CuratorStreak } from "./curator-types.js";
/**
* Pure date helpers — no DB, no side effects. Shared between curator-store
* (production) and streak-policy.test.ts (unit tests).
*/
export function todayIso(date = new Date()): string {
return date.toISOString().slice(0, 10);
}
export function addDaysIso(startDate: string, days: number): string {
const date = new Date(`${startDate}T00:00:00.000Z`);
date.setUTCDate(date.getUTCDate() + days);
return todayIso(date);
}
/**
* Pure streak computation from a descending-sorted list of completion day
* strings (YYYY-MM-DD). Extracted so streak policies (current/longest,
* gap reset, recovery, seven-day eligibility) can be unit-tested without
* a database.
*
* `days` MUST be sorted descending (most recent first) with no duplicates.
* The SQL GROUP BY in buildCuratorStreak ensures dedup in production; this
* function also deduplicates defensively so a duplicate day never inflates
* a streak.
*/
export function computeStreakFromDays(
days: string[],
today: string = todayIso(),
): CuratorStreak {
// Deduplicate defensively: a repeated day string must never inflate the count.
const uniqueDesc = [...new Set(days)];
let current = 0;
let cursor = today;
const daySet = new Set(uniqueDesc);
while (daySet.has(cursor)) {
current += 1;
cursor = addDaysIso(cursor, -1);
}
let longest = 0;
let run = 0;
let previous: string | null = null;
for (const day of [...uniqueDesc].reverse()) {
if (previous && addDaysIso(previous, 1) !== day) run = 0;
run += 1;
longest = Math.max(longest, run);
previous = day;
}
return {
current,
longest: Math.max(longest, current),
lastCompletedDate: uniqueDesc[0] ?? null,
};
}