Merge PRM-80 backend canonical service event contract
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"test:onboarding": "tsx scripts/onboarding-ledger.test.ts",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"workflows:smoke": "tsx src/workflows/smoke-test.ts",
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
completedAtFromOnboardingPayload,
|
||||
isValidOnboardingLedgerEvent,
|
||||
normalizeOnboardingEventType,
|
||||
} from "../src/events/onboarding-ledger.js";
|
||||
|
||||
const now = "2026-06-28T00:00:00.000Z";
|
||||
|
||||
assert.equal(normalizeOnboardingEventType("user_onboarding_completed"), "user.onboarding.completed");
|
||||
|
||||
assert.equal(
|
||||
isValidOnboardingLedgerEvent({
|
||||
type: "onboarding.completed",
|
||||
payload: {},
|
||||
}),
|
||||
true,
|
||||
"explicit completion event should satisfy onboarding status",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
isValidOnboardingLedgerEvent({
|
||||
type: "user.onboarding.completed",
|
||||
payload: {},
|
||||
}),
|
||||
true,
|
||||
"user completion event should satisfy onboarding status",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
isValidOnboardingLedgerEvent({
|
||||
type: "profile.onboarding.completed",
|
||||
payload: {},
|
||||
}),
|
||||
true,
|
||||
"profile completion event should satisfy onboarding status",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
isValidOnboardingLedgerEvent({
|
||||
type: "onboarding.snapshot.saved",
|
||||
payload: { onboarding: { current_step: 2 } },
|
||||
}),
|
||||
false,
|
||||
"intermediate snapshots must not bypass onboarding",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
isValidOnboardingLedgerEvent({
|
||||
type: "onboarding.snapshot.saved",
|
||||
payload: { onboarding: { completed_at: now } },
|
||||
}),
|
||||
true,
|
||||
"completion snapshots should satisfy onboarding status",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
completedAtFromOnboardingPayload({
|
||||
preferences: { onboarding: { completed_at: now } },
|
||||
}),
|
||||
now,
|
||||
"completion timestamp should be extracted from preferences snapshot",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
completedAtFromOnboardingPayload({
|
||||
completedAt: "not-a-date",
|
||||
})?.endsWith("Z"),
|
||||
true,
|
||||
"invalid completion timestamps should normalize to a valid ISO timestamp",
|
||||
);
|
||||
|
||||
console.log("onboarding-ledger tests passed");
|
||||
process.exit(0);
|
||||
@@ -33,7 +33,7 @@ const requiredServices = [
|
||||
|
||||
const directHealth = [
|
||||
["interview", "http://127.0.0.1:8007/health"],
|
||||
["roleplay", "http://127.0.0.1:8008/health"],
|
||||
["roleplay", "http://127.0.0.1:8040/health"],
|
||||
["resume", "http://127.0.0.1:8002/health"],
|
||||
["qscore", "http://127.0.0.1:8000/health"],
|
||||
["courses", "http://127.0.0.1:8060/api/v1/health"],
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { growEvents, type GrowEventRow } from "../db/schema.js";
|
||||
import { asRecord } from "./envelope.js";
|
||||
import {
|
||||
markGrowEventFailed,
|
||||
markGrowEventProcessed,
|
||||
markGrowEventProcessing,
|
||||
recordGrowEvent,
|
||||
} from "./record-grow-event.js";
|
||||
import { ensureOnboardingBaselineQscoreForCompletedAt } from "./onboarding-qscore.js";
|
||||
import {
|
||||
onboardingCompletedAtFromEvent,
|
||||
runCuratorOnboardingLoopSafely,
|
||||
} from "../v1/curator/curator-onboarding-loop.js";
|
||||
|
||||
export const ONBOARDING_LEDGER_EVENT_TYPES = [
|
||||
"onboarding.snapshot.saved",
|
||||
"onboarding.completed",
|
||||
"user.onboarding.completed",
|
||||
"profile.onboarding.completed",
|
||||
] as const;
|
||||
|
||||
export type OnboardingLedgerEventType = (typeof ONBOARDING_LEDGER_EVENT_TYPES)[number];
|
||||
|
||||
export const ONBOARDING_LEDGER_QUERY_TYPES = [
|
||||
...ONBOARDING_LEDGER_EVENT_TYPES,
|
||||
"onboarding_snapshot_saved",
|
||||
"onboarding_completed",
|
||||
"user_onboarding_completed",
|
||||
"profile_onboarding_completed",
|
||||
] as const;
|
||||
|
||||
export type OnboardingStatusEvent = {
|
||||
id: string;
|
||||
type: string;
|
||||
occurredAt: Date;
|
||||
processingStatus: string;
|
||||
};
|
||||
|
||||
const COMPLETION_EVENT_TYPES = new Set<string>([
|
||||
"onboarding.completed",
|
||||
"user.onboarding.completed",
|
||||
"profile.onboarding.completed",
|
||||
]);
|
||||
|
||||
export function normalizeOnboardingEventType(type: string) {
|
||||
return type.toLowerCase().replaceAll("_", ".");
|
||||
}
|
||||
|
||||
export function completedAtFromOnboardingPayload(payload: Record<string, unknown> | null | undefined) {
|
||||
const data = payload ?? {};
|
||||
const preferences = asRecord(data.preferences);
|
||||
const onboarding = asRecord(data.onboarding ?? preferences.onboarding);
|
||||
const candidate =
|
||||
data.completedAt ??
|
||||
data.completed_at ??
|
||||
data.onboardingCompletedAt ??
|
||||
data.onboarding_completed_at ??
|
||||
onboarding.completed_at ??
|
||||
onboarding.completedAt ??
|
||||
asRecord(preferences.onboarding).completed_at ??
|
||||
asRecord(preferences.onboarding).completedAt;
|
||||
|
||||
if (candidate instanceof Date) {
|
||||
return Number.isNaN(candidate.getTime()) ? undefined : candidate.toISOString();
|
||||
}
|
||||
if (typeof candidate !== "string" || !candidate.trim()) return undefined;
|
||||
const parsed = new Date(candidate);
|
||||
return Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString();
|
||||
}
|
||||
|
||||
export function isValidOnboardingLedgerEvent(event: Pick<GrowEventRow, "type" | "payload">) {
|
||||
const normalizedType = normalizeOnboardingEventType(event.type);
|
||||
if (COMPLETION_EVENT_TYPES.has(normalizedType)) return true;
|
||||
|
||||
// Snapshots are status-valid only when they are completion snapshots. Plain
|
||||
// intermediate step saves must not let a new seeker bypass onboarding.
|
||||
if (normalizedType === "onboarding.snapshot.saved") {
|
||||
return Boolean(completedAtFromOnboardingPayload(event.payload));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function getLatestValidOnboardingLedgerEvent(userId: string): Promise<OnboardingStatusEvent | null> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: growEvents.id,
|
||||
type: growEvents.type,
|
||||
payload: growEvents.payload,
|
||||
occurredAt: growEvents.occurredAt,
|
||||
processingStatus: growEvents.processingStatus,
|
||||
})
|
||||
.from(growEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(growEvents.userId, userId),
|
||||
inArray(growEvents.type, [...ONBOARDING_LEDGER_QUERY_TYPES]),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(25);
|
||||
|
||||
const row = rows.find((event) => isValidOnboardingLedgerEvent(event));
|
||||
if (!row) return null;
|
||||
const { payload: _payload, ...statusEvent } = row;
|
||||
return statusEvent;
|
||||
}
|
||||
|
||||
export async function ensureOnboardingBaselineQscoreFromLedger(userId: string) {
|
||||
const event = await getLatestValidOnboardingLedgerEvent(userId);
|
||||
if (!event) return false;
|
||||
return ensureOnboardingBaselineQscoreForCompletedAt(userId, event.occurredAt);
|
||||
}
|
||||
|
||||
function onboardingContextFromInput(context?: Record<string, unknown>) {
|
||||
const input = context ?? {};
|
||||
const preferences = asRecord(input.preferences);
|
||||
const onboarding = asRecord(input.onboarding ?? preferences.onboarding);
|
||||
return {
|
||||
...input,
|
||||
onboarding,
|
||||
preferences: Object.keys(preferences).length ? preferences : { onboarding },
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureOnboardingSideEffectsForEvent(event: GrowEventRow) {
|
||||
if (!event.userId || !isValidOnboardingLedgerEvent(event)) {
|
||||
return {
|
||||
qscoreBaselineSeeded: false,
|
||||
curatorOnboarding: { status: "skipped" as const, reason: event.userId ? "not_onboarding_completion" : "missing_user_id" },
|
||||
};
|
||||
}
|
||||
|
||||
const completedAt =
|
||||
onboardingCompletedAtFromEvent(event) ??
|
||||
completedAtFromOnboardingPayload(event.payload) ??
|
||||
event.occurredAt.toISOString();
|
||||
|
||||
const qscoreBaselineSeeded = await ensureOnboardingBaselineQscoreForCompletedAt(event.userId, completedAt);
|
||||
const curatorOnboarding = await runCuratorOnboardingLoopSafely({
|
||||
userId: event.userId,
|
||||
completedAt,
|
||||
sourceEventId: event.id,
|
||||
source: event.source,
|
||||
context: onboardingContextFromInput(event.payload),
|
||||
});
|
||||
|
||||
return { qscoreBaselineSeeded, curatorOnboarding };
|
||||
}
|
||||
|
||||
export async function recordAndProcessOnboardingCompletion(input: {
|
||||
userId: string;
|
||||
completedAt: string | Date;
|
||||
source?: string;
|
||||
context?: Record<string, unknown>;
|
||||
}) {
|
||||
const completedAt =
|
||||
input.completedAt instanceof Date
|
||||
? input.completedAt.toISOString()
|
||||
: input.completedAt;
|
||||
const context = onboardingContextFromInput(input.context);
|
||||
const event = await recordGrowEvent(
|
||||
{
|
||||
source: input.source ?? "onboarding",
|
||||
type: "onboarding.completed",
|
||||
category: "usage",
|
||||
userId: input.userId,
|
||||
occurredAt: completedAt,
|
||||
payload: {
|
||||
completedAt,
|
||||
...context,
|
||||
},
|
||||
dedupeKey: `onboarding:completed:${input.userId}`,
|
||||
},
|
||||
{ userId: input.userId, source: input.source ?? "onboarding" },
|
||||
);
|
||||
|
||||
if (event.processingStatus !== "processed") {
|
||||
await markGrowEventProcessing(event.id);
|
||||
}
|
||||
|
||||
const sideEffects = await ensureOnboardingSideEffectsForEvent(event);
|
||||
if (sideEffects.curatorOnboarding.status === "skipped" && sideEffects.curatorOnboarding.reason === "loop_failed") {
|
||||
await markGrowEventFailed(event.id, new Error("curator_onboarding_loop_failed"));
|
||||
} else if (event.processingStatus !== "processed") {
|
||||
await markGrowEventProcessed(event.id);
|
||||
}
|
||||
|
||||
return { event, ...sideEffects };
|
||||
}
|
||||
@@ -9,12 +9,18 @@ function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function parseCompletedAt(value: unknown): Date | null {
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? null : value;
|
||||
}
|
||||
if (typeof value !== "string" || !value.trim()) return null;
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
|
||||
}
|
||||
|
||||
function onboardingCompletedAt(preferences: Record<string, unknown> | undefined): Date | null {
|
||||
const onboarding = asRecord(preferences?.onboarding);
|
||||
const completedAt = onboarding.completed_at;
|
||||
if (typeof completedAt !== "string" || !completedAt.trim()) return null;
|
||||
const parsed = new Date(completedAt);
|
||||
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
|
||||
return parseCompletedAt(onboarding.completed_at ?? onboarding.completedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,7 +38,15 @@ export async function ensureOnboardingBaselineQscore(
|
||||
): Promise<boolean> {
|
||||
const completedAt = onboardingCompletedAt(preferences);
|
||||
if (!completedAt) return false;
|
||||
return ensureOnboardingBaselineQscoreForCompletedAt(userId, completedAt);
|
||||
}
|
||||
|
||||
export async function ensureOnboardingBaselineQscoreForCompletedAt(
|
||||
userId: string,
|
||||
completedAtInput: string | Date,
|
||||
): Promise<boolean> {
|
||||
const completedAt = parseCompletedAt(completedAtInput);
|
||||
if (!completedAt) return false;
|
||||
const latestSignals = await db
|
||||
.select({ signalId: growQscoreLatest.signalId, score: growQscoreLatest.score })
|
||||
.from(growQscoreLatest)
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { config } from "../config.js";
|
||||
import { log } from "../log.js";
|
||||
import { recordGrowEvent } from "./record-grow-event.js";
|
||||
import {
|
||||
markGrowEventFailed,
|
||||
markGrowEventProcessed,
|
||||
markGrowEventProcessing,
|
||||
recordGrowEvent,
|
||||
} from "./record-grow-event.js";
|
||||
import { routeGrowEventToUserActor } from "./route-to-user-actor.js";
|
||||
import { runCuratorOnboardingLoopForEventSafely } from "../v1/curator/curator-onboarding-loop.js";
|
||||
import { applyQscoreProjection } from "./projectors/qscore-projector.js";
|
||||
import { ensureOnboardingSideEffectsForEvent } from "./onboarding-ledger.js";
|
||||
|
||||
// This file has two Redis ingestion modes:
|
||||
// 1. Canonical GrowEvent stream: grow.events.raw — future service event bus.
|
||||
@@ -151,7 +157,23 @@ async function recordAndRoute(input: unknown) {
|
||||
await routeGrowEventToUserActor(event).catch((err) => {
|
||||
log.warn({ err, eventId: event.id, userId: event.userId }, "failed to route grow event to user actor");
|
||||
});
|
||||
await runCuratorOnboardingLoopForEventSafely(event);
|
||||
if (!event.userId || event.processingStatus === "processed") return event;
|
||||
|
||||
await markGrowEventProcessing(event.id);
|
||||
try {
|
||||
await applyQscoreProjection(event);
|
||||
const onboarding = await ensureOnboardingSideEffectsForEvent(event);
|
||||
if (
|
||||
onboarding.curatorOnboarding.status === "skipped" &&
|
||||
onboarding.curatorOnboarding.reason === "loop_failed"
|
||||
) {
|
||||
throw new Error("curator_onboarding_loop_failed");
|
||||
}
|
||||
await markGrowEventProcessed(event.id);
|
||||
} catch (err) {
|
||||
await markGrowEventFailed(event.id, err);
|
||||
throw err;
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from "../db/schema.js";
|
||||
import { interviewService, resumeService, roleplayService } from "../services/product-service-clients.js";
|
||||
import { buildServiceLink } from "../services/service-registry.js";
|
||||
import { ensureOnboardingBaselineQscore } from "../events/onboarding-qscore.js";
|
||||
import { ensureOnboardingBaselineQscoreFromLedger } from "../events/onboarding-ledger.js";
|
||||
import { HomeFeedAgentError, refineHomeNotificationsWithAgent } from "./home-feed-agent.js";
|
||||
import { listAvailableMissionDefinitions } from "../missions/registry.js";
|
||||
import { listServiceCapabilities } from "../workflows/service-capabilities.js";
|
||||
@@ -615,7 +615,7 @@ async function buildIdentity(ctx: HomeContext) {
|
||||
}
|
||||
|
||||
export async function getHomeFeed(userId: string, opts: { refresh?: boolean; userProfile?: Record<string, unknown>; preferences?: Record<string, unknown> } = {}): Promise<HomeFeedResponse> {
|
||||
await ensureOnboardingBaselineQscore(userId, opts.preferences);
|
||||
await ensureOnboardingBaselineQscoreFromLedger(userId);
|
||||
const ctx = await collectContext(userId, { userProfile: opts.userProfile, preferences: opts.preferences });
|
||||
const persisted = await readPersistedNotifications(userId);
|
||||
const newest = persisted[0]?.createdAt?.getTime() ?? 0;
|
||||
|
||||
+47
-8
@@ -4,9 +4,15 @@ import { config } from "../config.js";
|
||||
import { db } from "../db/client.js";
|
||||
import { growEvents } from "../db/schema.js";
|
||||
import { requireUser, type AuthContext } from "../auth/clerk.js";
|
||||
import { recordGrowEvent } from "../events/record-grow-event.js";
|
||||
import {
|
||||
markGrowEventFailed,
|
||||
markGrowEventProcessed,
|
||||
markGrowEventProcessing,
|
||||
recordGrowEvent,
|
||||
} from "../events/record-grow-event.js";
|
||||
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
|
||||
import { runCuratorOnboardingLoopForEventSafely } from "../v1/curator/curator-onboarding-loop.js";
|
||||
import { applyQscoreProjection } from "../events/projectors/qscore-projector.js";
|
||||
import { ensureOnboardingSideEffectsForEvent } from "../events/onboarding-ledger.js";
|
||||
|
||||
function serviceAuthorized(auth: string | undefined) {
|
||||
const token = (auth ?? "").replace(/^Bearer\s+/i, "").trim();
|
||||
@@ -21,8 +27,41 @@ async function ingest(body: unknown, userId?: string, source?: string) {
|
||||
routed: false as const,
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
}));
|
||||
const curatorOnboarding = await runCuratorOnboardingLoopForEventSafely(event);
|
||||
return { event, route, curatorOnboarding };
|
||||
if (event.processingStatus === "processed") {
|
||||
return {
|
||||
event,
|
||||
processingStatus: "processed" as const,
|
||||
route,
|
||||
qscore: { signals: [], score: undefined, idempotent: true },
|
||||
onboarding: { qscoreBaselineSeeded: false, curatorOnboarding: { status: "already_processed" } },
|
||||
};
|
||||
}
|
||||
if (!event.userId) {
|
||||
return {
|
||||
event,
|
||||
processingStatus: event.processingStatus,
|
||||
route,
|
||||
qscore: { signals: [], score: undefined, skipped: "missing_user_id" },
|
||||
onboarding: { qscoreBaselineSeeded: false, curatorOnboarding: { status: "skipped", reason: "missing_user_id" } },
|
||||
};
|
||||
}
|
||||
|
||||
await markGrowEventProcessing(event.id);
|
||||
try {
|
||||
const qscore = await applyQscoreProjection(event);
|
||||
const onboarding = await ensureOnboardingSideEffectsForEvent(event);
|
||||
if (
|
||||
onboarding.curatorOnboarding.status === "skipped" &&
|
||||
onboarding.curatorOnboarding.reason === "loop_failed"
|
||||
) {
|
||||
throw new Error("curator_onboarding_loop_failed");
|
||||
}
|
||||
await markGrowEventProcessed(event.id);
|
||||
return { event, processingStatus: "processed" as const, route, qscore, onboarding };
|
||||
} catch (err) {
|
||||
await markGrowEventFailed(event.id, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function eventRoutes() {
|
||||
@@ -32,8 +71,8 @@ export function eventRoutes() {
|
||||
app.post("/ingest", requireUser, async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
const { event, route, curatorOnboarding } = await ingest(body, userId);
|
||||
return c.json({ eventId: event.id, processingStatus: event.processingStatus, route, curatorOnboarding }, 202);
|
||||
const { event, processingStatus, route, qscore, onboarding } = await ingest(body, userId);
|
||||
return c.json({ eventId: event.id, processingStatus, route, qscore, onboarding }, 202);
|
||||
});
|
||||
|
||||
// Service-to-service ingress. Services may include userId directly, or we resolve it from session correlation.
|
||||
@@ -43,8 +82,8 @@ export function eventRoutes() {
|
||||
}
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
const source = c.req.header("x-growqr-source") ?? undefined;
|
||||
const { event, route, curatorOnboarding } = await ingest(body, undefined, source);
|
||||
return c.json({ eventId: event.id, processingStatus: event.processingStatus, route, curatorOnboarding }, 202);
|
||||
const { event, processingStatus, route, qscore, onboarding } = await ingest(body, undefined, source);
|
||||
return c.json({ eventId: event.id, processingStatus, route, qscore, onboarding }, 202);
|
||||
});
|
||||
|
||||
app.get("/", requireUser, async (c) => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { db } from "../db/client.js";
|
||||
import { events, growQscoreLatest, growQscoreProjectionState } from "../db/schema.js";
|
||||
import { recordGrowEvent } from "../events/record-grow-event.js";
|
||||
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
|
||||
import { ensureOnboardingBaselineQscore } from "../events/onboarding-qscore.js";
|
||||
import { ensureOnboardingBaselineQscoreFromLedger } from "../events/onboarding-ledger.js";
|
||||
import { getRequestUserPreferences, getRequestUserProfile } from "../services/user-context.js";
|
||||
import { log } from "../log.js";
|
||||
|
||||
@@ -655,7 +655,7 @@ export function serviceRoutes() {
|
||||
app.get("/qscore/current", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
try {
|
||||
await ensureOnboardingBaselineQscore(userId, await getRequestUserPreferences(c.req.raw, userId));
|
||||
await ensureOnboardingBaselineQscoreFromLedger(userId);
|
||||
} catch (err) {
|
||||
log.warn({ err, userId }, "failed to seed onboarding Q Score baseline before current Q Score read");
|
||||
}
|
||||
|
||||
+8
-38
@@ -1,22 +1,17 @@
|
||||
import { Hono } from "hono";
|
||||
import { requireUser, type AuthContext } from "../auth/clerk.js";
|
||||
import { db } from "../db/client.js";
|
||||
import { growEvents, users, userStacks, type UserStack } from "../db/schema.js";
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import { users, userStacks, type UserStack } from "../db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { log } from "../log.js";
|
||||
import { config } from "../config.js";
|
||||
import { ensureOnboardingBaselineQscore } from "../events/onboarding-qscore.js";
|
||||
import {
|
||||
onboardingCompletedAtFromPreferences,
|
||||
runCuratorOnboardingLoopSafely,
|
||||
} from "../v1/curator/curator-onboarding-loop.js";
|
||||
|
||||
const ONBOARDING_LEDGER_EVENT_TYPES = [
|
||||
"onboarding.snapshot.saved",
|
||||
"onboarding.completed",
|
||||
"user.onboarding.completed",
|
||||
"profile.onboarding.completed",
|
||||
] as const;
|
||||
import {
|
||||
getLatestValidOnboardingLedgerEvent,
|
||||
recordAndProcessOnboardingCompletion,
|
||||
} from "../events/onboarding-ledger.js";
|
||||
|
||||
function publicStack(stack: UserStack | null | undefined) {
|
||||
if (!stack) return stack;
|
||||
@@ -62,27 +57,6 @@ async function ensureUserServiceUser(req: Request) {
|
||||
return res.json() as Promise<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
async function getOnboardingEventStatus(userId: string) {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: growEvents.id,
|
||||
type: growEvents.type,
|
||||
occurredAt: growEvents.occurredAt,
|
||||
processingStatus: growEvents.processingStatus,
|
||||
})
|
||||
.from(growEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(growEvents.userId, userId),
|
||||
inArray(growEvents.type, [...ONBOARDING_LEDGER_EVENT_TYPES]),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(1);
|
||||
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export function userRoutes() {
|
||||
const app = new Hono<AuthContext>();
|
||||
app.use("*", requireUser);
|
||||
@@ -120,7 +94,7 @@ export function userRoutes() {
|
||||
|
||||
app.get("/onboarding-status", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const event = await getOnboardingEventStatus(userId);
|
||||
const event = await getLatestValidOnboardingLedgerEvent(userId);
|
||||
|
||||
return c.json({
|
||||
userId,
|
||||
@@ -149,13 +123,9 @@ export function userRoutes() {
|
||||
const normalizedPreferences = preferences && typeof preferences === "object" && !Array.isArray(preferences)
|
||||
? (preferences as Record<string, unknown>)
|
||||
: undefined;
|
||||
await ensureOnboardingBaselineQscore(
|
||||
c.get("userId"),
|
||||
normalizedPreferences,
|
||||
);
|
||||
const completedAt = onboardingCompletedAtFromPreferences(normalizedPreferences);
|
||||
if (completedAt) {
|
||||
await runCuratorOnboardingLoopSafely({
|
||||
await recordAndProcessOnboardingCompletion({
|
||||
userId: c.get("userId"),
|
||||
completedAt,
|
||||
source: "user-service-profile",
|
||||
|
||||
@@ -62,6 +62,19 @@ export type ServiceRecord = {
|
||||
qscoreSignals?: string[];
|
||||
usage: string;
|
||||
};
|
||||
eventContract: {
|
||||
canonicalServiceId: string;
|
||||
openEvents: string[];
|
||||
startedEvents: string[];
|
||||
configuredEvents: string[];
|
||||
completionEvents: string[];
|
||||
abandonmentEvents: string[];
|
||||
qscoreSignalIds: readonly string[];
|
||||
analyticsDimensions: readonly string[];
|
||||
requiredCorrelationFields: readonly string[];
|
||||
handoffParams: readonly string[];
|
||||
actionKind: "active_user_action" | "passive_background_action" | "measurement_review";
|
||||
};
|
||||
usageDocs: string[];
|
||||
};
|
||||
|
||||
@@ -196,7 +209,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
media: "video",
|
||||
},
|
||||
toolName: "prepare_interview_preview",
|
||||
completionEvents: ["interview.session.configured", "interview.feedback.generated", "interview.completed"],
|
||||
completionEvents: ["interview.session.configured", "interview.session.completed", "interview.feedback.generated"],
|
||||
qscoreSignals: ["communication.interview", "proof.story_bank", "readiness.practice"],
|
||||
usage: "Include missionInstanceId, missionId, stageId, curatorTaskId, role, and media when building stateful handoffs.",
|
||||
},
|
||||
@@ -204,6 +217,19 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
"Call buildServiceLink('interview-service', 'preview', state) for curator handoffs.",
|
||||
"Call getServiceEndpoint('interview-service', 'configure') for backend contract metadata.",
|
||||
],
|
||||
eventContract: {
|
||||
canonicalServiceId: "interview",
|
||||
openEvents: ["task.opened", "interview.preview.opened"],
|
||||
startedEvents: ["service.started", "interview.session.started"],
|
||||
configuredEvents: ["service.configured", "interview.session.configured"],
|
||||
completionEvents: ["service.completed", "interview.session.completed", "interview.feedback.generated"],
|
||||
abandonmentEvents: ["service.abandoned", "interview.session.abandoned"],
|
||||
qscoreSignalIds: ["interview.communication_score", "interview.confidence_score", "readiness.practice"],
|
||||
analyticsDimensions: ["interview_type", "target_role", "difficulty", "duration_minutes", "overall_score"],
|
||||
requiredCorrelationFields: ["taskId"],
|
||||
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
||||
actionKind: "active_user_action",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "roleplay-service",
|
||||
@@ -281,7 +307,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
mode: "video",
|
||||
},
|
||||
toolName: "prepare_roleplay_preview",
|
||||
completionEvents: ["roleplay.scenario.configured", "roleplay.feedback.generated", "roleplay.completed"],
|
||||
completionEvents: ["roleplay.scenario.configured", "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.",
|
||||
},
|
||||
@@ -289,6 +315,19 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
"Call buildServiceLink('roleplay-service', 'builder', state) for curator handoffs.",
|
||||
"Call getServiceEndpoint('roleplay-service', 'configure') for backend contract metadata.",
|
||||
],
|
||||
eventContract: {
|
||||
canonicalServiceId: "roleplay",
|
||||
openEvents: ["task.opened", "roleplay.preview.opened"],
|
||||
startedEvents: ["service.started", "roleplay.scenario.started"],
|
||||
configuredEvents: ["service.configured", "roleplay.scenario.configured"],
|
||||
completionEvents: ["service.completed", "roleplay.scenario.completed", "roleplay.feedback.generated"],
|
||||
abandonmentEvents: ["service.abandoned", "roleplay.scenario.abandoned"],
|
||||
qscoreSignalIds: ["roleplay.negotiation_confidence", "communication.roleplay", "readiness.practice"],
|
||||
analyticsDimensions: ["roleplay_type", "target_role", "difficulty", "duration_minutes", "overall_score"],
|
||||
requiredCorrelationFields: ["taskId"],
|
||||
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
||||
actionKind: "active_user_action",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "resume-service",
|
||||
@@ -356,7 +395,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
tab: "resumes",
|
||||
},
|
||||
toolName: "prepare_resume_upload",
|
||||
completionEvents: ["resume.analysis.completed", "resume.parsed", "resume.updated"],
|
||||
completionEvents: ["resume.analysis.completed", "resume.parsed", "resume.updated", "resume.exported"],
|
||||
qscoreSignals: ["proof.resume", "readiness.ats", "profile.skills"],
|
||||
usage: "Include mission state and optional section when linking into resume work.",
|
||||
},
|
||||
@@ -364,6 +403,19 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
"Call buildServiceLink('resume-service', 'workspace', { tab: 'resumes' }) for curator handoffs.",
|
||||
"Use the resume gateway proxy for browser calls that need Clerk auth.",
|
||||
],
|
||||
eventContract: {
|
||||
canonicalServiceId: "resume",
|
||||
openEvents: ["task.opened"],
|
||||
startedEvents: ["service.started", "resume.uploaded"],
|
||||
configuredEvents: ["service.configured", "resume.parsed"],
|
||||
completionEvents: ["service.completed", "resume.analysis.completed", "resume.updated", "resume.exported"],
|
||||
abandonmentEvents: ["service.abandoned"],
|
||||
qscoreSignalIds: ["resume.ats_score", "resume.profile_completeness", "proof.resume"],
|
||||
analyticsDimensions: ["resume_id", "version_id", "ats_score", "role_fit_score", "updated_sections"],
|
||||
requiredCorrelationFields: ["taskId"],
|
||||
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
||||
actionKind: "active_user_action",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "cover-letter-service",
|
||||
@@ -424,7 +476,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
tab: "cover-letters",
|
||||
},
|
||||
toolName: "prepare_cover_letter_handoff",
|
||||
completionEvents: ["cover_letter.generated", "cover_letter.updated", "cover_letter.analysis_completed"],
|
||||
completionEvents: ["cover_letter.generated", "cover_letter.edited", "cover_letter.exported"],
|
||||
qscoreSignals: ["proof.cover_letter", "readiness.application"],
|
||||
usage: "Use for application-specific artifact tasks; share mission/job context in query or payload.",
|
||||
},
|
||||
@@ -432,6 +484,19 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
"Call buildServiceLink('cover-letter-service', 'workspace', { tab: 'cover-letters' }) for curator handoffs.",
|
||||
"Backend endpoint metadata maps to resume-builder cover-letter APIs.",
|
||||
],
|
||||
eventContract: {
|
||||
canonicalServiceId: "cover_letter",
|
||||
openEvents: ["task.opened"],
|
||||
startedEvents: ["service.started"],
|
||||
configuredEvents: ["service.configured"],
|
||||
completionEvents: ["service.completed", "cover_letter.generated", "cover_letter.edited", "cover_letter.exported"],
|
||||
abandonmentEvents: ["service.abandoned"],
|
||||
qscoreSignalIds: ["proof.cover_letter", "readiness.application"],
|
||||
analyticsDimensions: ["cover_letter_id", "role_fit_score", "export_format"],
|
||||
requiredCorrelationFields: ["taskId"],
|
||||
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
||||
actionKind: "active_user_action",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "courses-service",
|
||||
@@ -482,11 +547,24 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
start: "Start course",
|
||||
},
|
||||
toolName: "prepare_course_handoff",
|
||||
completionEvents: ["course.started", "course.completed"],
|
||||
completionEvents: ["course.generated", "course.started", "course.lesson.completed", "course.completed", "course.weakness.applied"],
|
||||
qscoreSignals: ["skills.learning", "readiness.course_progress"],
|
||||
usage: "Use for skill-gap tasks that should become learning work.",
|
||||
},
|
||||
usageDocs: ["Call buildServiceLink('courses-service', 'catalog', state) for course handoffs."],
|
||||
eventContract: {
|
||||
canonicalServiceId: "course",
|
||||
openEvents: ["task.opened"],
|
||||
startedEvents: ["service.started", "course.started"],
|
||||
configuredEvents: ["course.generated"],
|
||||
completionEvents: ["service.completed", "course.lesson.completed", "course.completed", "course.weakness.applied"],
|
||||
abandonmentEvents: ["service.abandoned"],
|
||||
qscoreSignalIds: ["course.skill_gap_closed", "skills.learning", "readiness.course_progress"],
|
||||
analyticsDimensions: ["course_id", "lesson_id", "progress_percentage", "weakness_tags"],
|
||||
requiredCorrelationFields: ["taskId"],
|
||||
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
||||
actionKind: "active_user_action",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "assessment-service",
|
||||
@@ -528,11 +606,24 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
review: "Review assessment",
|
||||
},
|
||||
toolName: "prepare_assessment_handoff",
|
||||
completionEvents: ["assessment.started", "assessment.submitted", "assessment.completed"],
|
||||
completionEvents: ["assessment.started", "assessment.completed"],
|
||||
qscoreSignals: ["skills.assessment", "readiness.measurement"],
|
||||
usage: "Use when a task needs a scoreable assessment rather than practice.",
|
||||
},
|
||||
usageDocs: ["Call buildServiceLink('assessment-service', 'assessment', state) for assessment handoffs."],
|
||||
eventContract: {
|
||||
canonicalServiceId: "assessment",
|
||||
openEvents: ["task.opened"],
|
||||
startedEvents: ["service.started", "assessment.started"],
|
||||
configuredEvents: [],
|
||||
completionEvents: ["service.completed", "assessment.completed"],
|
||||
abandonmentEvents: ["service.abandoned"],
|
||||
qscoreSignalIds: ["skills.assessment", "readiness.measurement"],
|
||||
analyticsDimensions: ["assessment_id", "skill_area", "score", "weakness_tags"],
|
||||
requiredCorrelationFields: ["taskId"],
|
||||
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
||||
actionKind: "measurement_review",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "matchmaking-service",
|
||||
@@ -577,7 +668,15 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
review: "Review matches",
|
||||
},
|
||||
toolName: "prepare_matchmaking_handoff",
|
||||
completionEvents: ["matchmaking.feed_viewed", "matchmaking.match_saved", "matchmaking.preference_updated"],
|
||||
completionEvents: [
|
||||
"matchmaking.matches.generated",
|
||||
"matchmaking.feed.viewed",
|
||||
"matchmaking.match.viewed",
|
||||
"matchmaking.match.saved",
|
||||
"matchmaking.match.dismissed",
|
||||
"matchmaking.application.started",
|
||||
"matchmaking.application.completed",
|
||||
],
|
||||
qscoreSignals: ["market.matches", "networking.opportunities"],
|
||||
usage: "Use for immediate opportunity matching or mentor/network suggestions.",
|
||||
},
|
||||
@@ -585,6 +684,26 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
"Call buildServiceLink('matchmaking-service', 'jobs', state) for job matching.",
|
||||
"Use the canonical /agents/matchmaking page for Scout-style opportunity review.",
|
||||
],
|
||||
eventContract: {
|
||||
canonicalServiceId: "matchmaking",
|
||||
openEvents: ["task.opened", "matchmaking.feed.viewed"],
|
||||
startedEvents: ["service.started", "matchmaking.application.started"],
|
||||
configuredEvents: [],
|
||||
completionEvents: [
|
||||
"service.completed",
|
||||
"matchmaking.matches.generated",
|
||||
"matchmaking.match.viewed",
|
||||
"matchmaking.match.saved",
|
||||
"matchmaking.match.dismissed",
|
||||
"matchmaking.application.completed",
|
||||
],
|
||||
abandonmentEvents: ["service.abandoned"],
|
||||
qscoreSignalIds: ["matchmaking.role_fit", "market.matches", "networking.opportunities"],
|
||||
analyticsDimensions: ["match_count", "role_ids", "role_fit", "saved_count", "dismissed_reason"],
|
||||
requiredCorrelationFields: ["taskId"],
|
||||
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
||||
actionKind: "active_user_action",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "qscore-service",
|
||||
@@ -625,11 +744,24 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
review: "Review QScore",
|
||||
},
|
||||
toolName: "prepare_qscore_review",
|
||||
completionEvents: ["qscore.updated", "qscore.signal.projected"],
|
||||
completionEvents: ["qscore.baseline.created", "qscore.review.opened", "qscore.weak_driver.reviewed", "qscore.signal.projected", "qscore.updated"],
|
||||
qscoreSignals: ["qscore.updated", "qscore.signal.projected"],
|
||||
usage: "Use for measurement and projected readiness review tasks.",
|
||||
},
|
||||
usageDocs: ["Call buildServiceLink('qscore-service', 'dashboard', state) for QScore handoffs."],
|
||||
eventContract: {
|
||||
canonicalServiceId: "qscore",
|
||||
openEvents: ["qscore.review.opened"],
|
||||
startedEvents: ["service.started"],
|
||||
configuredEvents: ["qscore.baseline.created"],
|
||||
completionEvents: ["qscore.signal.projected", "qscore.updated", "qscore.weak_driver.reviewed"],
|
||||
abandonmentEvents: [],
|
||||
qscoreSignalIds: ["qscore.updated", "qscore.signal.projected"],
|
||||
analyticsDimensions: ["score_before", "score_after", "signal_id", "delta", "trend_label"],
|
||||
requiredCorrelationFields: [],
|
||||
handoffParams: [],
|
||||
actionKind: "measurement_review",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "social-branding-service",
|
||||
@@ -672,11 +804,24 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
review: "Review profile",
|
||||
},
|
||||
toolName: "prepare_social_branding_handoff",
|
||||
completionEvents: ["social.profile_updated", "social.linkedin_connected", "social.branding_analyzed"],
|
||||
completionEvents: ["brand.post.drafted", "brand.post.edited", "brand.post.exported", "brand.profile.updated", "brand.content.published"],
|
||||
qscoreSignals: ["profile.linkedin", "proof.visibility", "networking.brand"],
|
||||
usage: "Use for profile visibility, LinkedIn cleanup, and social proof tasks.",
|
||||
},
|
||||
usageDocs: ["Call buildServiceLink('social-branding-service', 'profile', state) for social branding handoffs."],
|
||||
eventContract: {
|
||||
canonicalServiceId: "brand",
|
||||
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"],
|
||||
abandonmentEvents: ["service.abandoned"],
|
||||
qscoreSignalIds: ["brand.visibility_signal", "profile.linkedin", "proof.visibility"],
|
||||
analyticsDimensions: ["content_type", "channel", "draft_id", "topic", "profile_field_updated"],
|
||||
requiredCorrelationFields: ["taskId"],
|
||||
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
||||
actionKind: "active_user_action",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -751,6 +896,7 @@ export function listServicesForCatalog() {
|
||||
},
|
||||
frontend: service.frontend,
|
||||
curator: service.curator,
|
||||
eventContract: service.eventContract,
|
||||
usageDocs: service.usageDocs,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -12,6 +12,16 @@ import type { CuratorSprintResponse } from "./curator-types.js";
|
||||
const CURATOR_SOURCE = "curator-v1";
|
||||
const ONBOARDING_READY_EVENT = "curator.onboarding_plan.ready";
|
||||
const ONBOARDING_SKIPPED_EVENT = "curator.onboarding_plan.skipped";
|
||||
const ONBOARDING_CONTEXT_EVENT_TYPES = [
|
||||
"onboarding.snapshot.saved",
|
||||
"onboarding.completed",
|
||||
"user.onboarding.completed",
|
||||
"profile.onboarding.completed",
|
||||
"onboarding_snapshot_saved",
|
||||
"onboarding_completed",
|
||||
"user_onboarding_completed",
|
||||
"profile_onboarding_completed",
|
||||
] as const;
|
||||
|
||||
type OnboardingLoopInput = {
|
||||
userId: string;
|
||||
@@ -140,7 +150,7 @@ async function findLatestOnboardingContext(userId: string) {
|
||||
.from(growEvents)
|
||||
.where(and(
|
||||
eq(growEvents.userId, userId),
|
||||
inArray(growEvents.type, ["onboarding.snapshot.saved", "onboarding.completed", "user.onboarding.completed", "profile.onboarding.completed"]),
|
||||
inArray(growEvents.type, [...ONBOARDING_CONTEXT_EVENT_TYPES]),
|
||||
))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(1);
|
||||
|
||||
@@ -25,12 +25,22 @@ const VALID_COMPLETION_TYPES = [
|
||||
"resume.analysis.completed",
|
||||
"resume.parsed",
|
||||
"resume.updated",
|
||||
"resume.exported",
|
||||
"interview.session.configured",
|
||||
"interview.session.completed",
|
||||
"interview.feedback.generated",
|
||||
"interview.completed",
|
||||
"roleplay.scenario.configured",
|
||||
"roleplay.scenario.completed",
|
||||
"roleplay.feedback.generated",
|
||||
"roleplay.completed",
|
||||
"assessment.completed",
|
||||
"course.completed",
|
||||
"course.lesson.completed",
|
||||
"brand.post.exported",
|
||||
"brand.profile.updated",
|
||||
"matchmaking.matches.generated",
|
||||
"matchmaking.feed.viewed",
|
||||
"matchmaking.match.saved",
|
||||
"matchmaking.application.completed",
|
||||
"qscore.updated",
|
||||
"qscore.signal.projected",
|
||||
"curator.task.completed",
|
||||
|
||||
@@ -4,7 +4,7 @@ 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";
|
||||
import { ensureOnboardingSideEffectsForEvent } from "../../events/onboarding-ledger.js";
|
||||
|
||||
const eventTrackSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
@@ -90,14 +90,20 @@ export function v1EventRoutes() {
|
||||
try {
|
||||
const serviceSession = await applyServiceSessionProjection(event);
|
||||
const qscore = await applyQscoreProjection(event);
|
||||
const curatorOnboarding = await runCuratorOnboardingLoopForEventSafely(event);
|
||||
const onboarding = await ensureOnboardingSideEffectsForEvent(event);
|
||||
if (
|
||||
onboarding.curatorOnboarding.status === "skipped" &&
|
||||
onboarding.curatorOnboarding.reason === "loop_failed"
|
||||
) {
|
||||
throw new Error("curator_onboarding_loop_failed");
|
||||
}
|
||||
await markGrowEventProcessed(event.id);
|
||||
return c.json({
|
||||
eventId: event.id,
|
||||
processingStatus: "processed",
|
||||
serviceSession,
|
||||
qscore,
|
||||
curatorOnboarding,
|
||||
onboarding,
|
||||
}, 202);
|
||||
} catch (err) {
|
||||
await markGrowEventFailed(event.id, err);
|
||||
|
||||
@@ -17,6 +17,7 @@ export type ServiceCapability = {
|
||||
backend?: unknown;
|
||||
frontend?: unknown;
|
||||
curator?: unknown;
|
||||
eventContract?: unknown;
|
||||
usageDocs?: string[];
|
||||
};
|
||||
|
||||
@@ -43,6 +44,7 @@ export function listServiceCapabilities(opts: { public?: boolean } = {}): Servic
|
||||
},
|
||||
frontend: service.frontend,
|
||||
curator: service.curator,
|
||||
eventContract: service.eventContract,
|
||||
usageDocs: service.usageDocs,
|
||||
}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user