diff --git a/.changeset/launch-promo-foundation-724.md b/.changeset/launch-promo-foundation-724.md new file mode 100644 index 00000000..187b4c41 --- /dev/null +++ b/.changeset/launch-promo-foundation-724.md @@ -0,0 +1,26 @@ +--- +"ornn-api": minor +--- + +Launch-promo foundation — admin-driven manual award flow + caller status (#724, PR 1 of 2). + +The landing/news page promises that the first 500 Ornn users who star the GitHub repo and sign in receive a redemption code (200 Playground + 200 Skill Generation credits) plus the NyxID invite code, delivered to the Ornn notification inbox within 24 h. This PR lands the foundation that lets an admin honour that promise today, and gives the calling user a way to see their eligibility. + +What ships: + +- **`launchPromo` settings section** — `enabled`, `repoOwner`, `repoName`, `totalSlots` (default 500), `awardPlayground` / `awardSkillGen` (default 200), `pollIntervalMs`, `codeExpiryDays`, `nyxidInviteCode`. Defaults are conservative (`enabled: false`, empty repo, empty invite code) so the promo stays dormant until an admin explicitly turns it on. +- **`launch_promo_claims` collection + repo** — one doc per awarded user, keyed on `_id = userId` for primary-key idempotency. Fields: `eligibilityRank`, `redemptionCodeId`, `awardedAt`, `awardedBy`, optional `githubLogin`. Index on `awardedAt desc` for admin observability. +- **`LaunchPromoService.awardUser({ userId, awardedBy, githubLogin? })`** — gates on enabled + rank ≤ `totalSlots` + slots remaining + not-already-claimed, mints a redemption code via the existing redemption-codes service (no quota write — user redeems themselves through Settings → Redeem), drops a `launchPromo.codeDelivered` notification containing the code + NyxID invite code, then records the claim. Duplicate-key during insert (race) cleanly resolves to `ALREADY_CLAIMED`. Notification failure is logged but doesn't roll back the claim — the user already has the grant; admins can resend. +- **`LaunchPromoService.getStatusForUser(userId)`** — composes `{ promoEnabled, claimed, rank, totalSlots, slotsRemaining, awardedAt }` for the `/me/launch-promo` endpoint. +- **`UserDirectoryRepository.getRegistrationRank(userId)`** — 1-based ordering by `firstSeenAt asc`. Two queries (PK lookup + filter count), no scan. +- **New `launchPromo.codeDelivered` NotificationCategory.** +- **Routes** — `GET /me/launch-promo`, `POST /admin/launch-promo/award/:userId` (gated on `ornn:admin:skill`), `GET /admin/launch-promo/recent` for observability. Service-layer error sentinels (`PROMO_DISABLED`, `RANK_EXCEEDED`, `SLOTS_EXHAUSTED`, `ALREADY_CLAIMED`, `USER_NOT_FOUND`) map to 400 / 403 / 409 / 404. + +What is **deferred to PR 2** (clearly TODO in the design comments): + +- GitHub stargazers HTTP client (public API, no auth) + cron loop driven by `pollIntervalMs`. +- NyxID → GitHub-login resolution (currently the manual admin flow doesn't need this; the cron path will once it lands). +- Frontend admin UI for the settings section + "recent awards" panel. +- Frontend caller-side display ("you're in the first N — claim ready / claimed"). + +Coverage: 12 colocated unit tests on `LaunchPromoService` covering the happy path, every error sentinel, race-on-insert resolving to `ALREADY_CLAIMED`, and notification-failure-does-not-rollback. All green. diff --git a/ornn-api/src/bootstrap.ts b/ornn-api/src/bootstrap.ts index 06039a2d..0a17e8be 100644 --- a/ornn-api/src/bootstrap.ts +++ b/ornn-api/src/bootstrap.ts @@ -67,6 +67,7 @@ import { createAuditRoutes } from "./domains/skills/audit/routes"; // Domain: Notifications import { wireNotifications } from "./domains/notifications/bootstrap"; +import { wireLaunchPromo } from "./domains/launchPromo/bootstrap"; // Domain: Announcements (landing-page popup) import { wireAnnouncements } from "./domains/announcements/bootstrap"; @@ -470,12 +471,15 @@ export async function bootstrap(config: SkillConfig): Promise { db, logger, }); - const { service: notificationService, routes: notificationRoutes } = - await wireNotifications({ - db, - logger, - broadcastRepo: broadcastRepoForNotifications, - }); + const { + service: notificationService, + routes: notificationRoutes, + repo: notificationRepo, + } = await wireNotifications({ + db, + logger, + broadcastRepo: broadcastRepoForNotifications, + }); // ---- Domain: Announcements (landing-page popup, issue #307) ---- const { routes: announcementRoutes } = await wireAnnouncements({ db, logger }); @@ -607,10 +611,26 @@ export async function bootstrap(config: SkillConfig): Promise { // ---- Domain: Redemption codes (single-use admin-issued quota grants) ---- const { + service: redemptionCodeService, adminRoutes: adminRedemptionCodesRoutes, meRoutes: meRedemptionCodesRoutes, } = wireRedemptionCodes({ db, logger, quotaService }); + // ---- Domain: Launch promo (#724) ---- + // Sits on top of redemption codes + notifications: when an admin + // (or, in a follow-up PR, the cron loop) awards an eligible user, + // the service mints a code via redemptionCodeService.mint and drops + // it into the user's notification inbox. + const { service: launchPromoService, routes: launchPromoRoutes } = + await wireLaunchPromo({ + db, + settingsService, + redemptionCodeService, + notificationRepo, + userDirectoryRepo, + }); + void launchPromoService; + // ---- Per-provider model catalog migration (#270) ---- // Fold the standalone `models` collection into `llm_providers.models[]` // arrays. One-time, idempotent — see `migration.ts`. Must run before @@ -888,6 +908,7 @@ export async function bootstrap(config: SkillConfig): Promise { apiApp.route("/", mirrorRoutes); apiApp.route("/", auditRoutes); apiApp.route("/", notificationRoutes); + apiApp.route("/", launchPromoRoutes); apiApp.route("/", announcementRoutes); apiApp.route("/", broadcastRoutes); apiApp.route("/", analyticsRoutes); diff --git a/ornn-api/src/domains/launchPromo/bootstrap.ts b/ornn-api/src/domains/launchPromo/bootstrap.ts new file mode 100644 index 00000000..e5b2fd6a --- /dev/null +++ b/ornn-api/src/domains/launchPromo/bootstrap.ts @@ -0,0 +1,55 @@ +/** + * Launch-promo domain bootstrap (#724) — repo + service + routes. + * + * The cron loop + GitHub stargazers HTTP client + NyxID GH-login + * resolver land in a follow-up PR. This bootstrap exposes everything + * needed for the admin manual-award + caller-status endpoints to work + * today. + * + * @module domains/launchPromo/bootstrap + */ + +import type { Hono } from "hono"; +import type { Db } from "mongodb"; +import type { AuthVariables } from "../../middleware/nyxidAuth"; +import { LaunchPromoRepository } from "./repository"; +import { LaunchPromoService } from "./service"; +import { createLaunchPromoRoutes } from "./routes"; +import type { SettingsService } from "../settings/types"; +import type { RedemptionCodeService } from "../redemption-codes/service"; +import type { NotificationRepository } from "../notifications/repository"; +import type { UserDirectoryRepository } from "../users/repository"; + +export interface LaunchPromoWiring { + readonly service: LaunchPromoService; + readonly routes: Hono<{ Variables: AuthVariables }>; +} + +export interface LaunchPromoWiringDeps { + db: Db; + settingsService: SettingsService; + redemptionCodeService: RedemptionCodeService; + notificationRepo: NotificationRepository; + userDirectoryRepo: UserDirectoryRepository; +} + +export async function wireLaunchPromo( + deps: LaunchPromoWiringDeps, +): Promise { + const repo = new LaunchPromoRepository(deps.db); + await repo.ensureIndexes().catch(() => { + /* index creation is best-effort; first write still succeeds without it */ + }); + + const service = new LaunchPromoService({ + repo, + userDirectoryRepo: deps.userDirectoryRepo, + settingsService: deps.settingsService, + redemptionCodeService: deps.redemptionCodeService, + notificationRepo: deps.notificationRepo, + }); + + const routes = createLaunchPromoRoutes({ service }); + + return { service, routes }; +} diff --git a/ornn-api/src/domains/launchPromo/repository.ts b/ornn-api/src/domains/launchPromo/repository.ts new file mode 100644 index 00000000..7ab0b22b --- /dev/null +++ b/ornn-api/src/domains/launchPromo/repository.ts @@ -0,0 +1,84 @@ +/** + * Launch-promo claims repository (#724). + * + * Wraps the single `launch_promo_claims` collection. A claim doc is + * the source-of-truth for "this Ornn user has been awarded the launch + * promo grant" — its presence alone is the idempotency gate; we never + * mint twice for the same user. + * + * @module domains/launchPromo/repository + */ + +import type { Collection, Db } from "mongodb"; +import { createLogger } from "../../shared/logger"; +import type { LaunchPromoClaimDoc } from "./types"; + +const logger = createLogger("launchPromoRepository"); + +export class LaunchPromoRepository { + private readonly collection: Collection; + + constructor(db: Db) { + this.collection = db.collection("launch_promo_claims"); + } + + async ensureIndexes(): Promise { + // `_id` is auto-indexed; the second index sorts the admin overview + // by award order without paying for a doc scan. + await this.collection.createIndex( + { awardedAt: -1 }, + { name: "launch_promo_awardedAt_desc" }, + ); + } + + /** Has this user already been awarded? Primary-key lookup. */ + async hasClaimed(userId: string): Promise { + const doc = await this.collection.findOne( + { _id: userId }, + { projection: { _id: 1 } }, + ); + return !!doc; + } + + /** Read the full claim doc (or null if no claim). */ + async findByUserId(userId: string): Promise { + return this.collection.findOne({ _id: userId }); + } + + /** + * Insert a claim row. Throws on duplicate-key (caller treats that + * as "someone else's race won" and skips). + */ + async insert(doc: LaunchPromoClaimDoc): Promise { + try { + await this.collection.insertOne(doc); + logger.info( + { + userId: doc._id, + rank: doc.eligibilityRank, + redemptionCodeId: doc.redemptionCodeId, + awardedBy: doc.awardedBy, + }, + "Launch-promo claim recorded", + ); + } catch (err) { + // Duplicate-key error code is 11000. Bubble up so the service + // can short-circuit cleanly. + throw err; + } + } + + /** Count of awarded claims — the slot-utilisation gate. */ + async countAwarded(): Promise { + return this.collection.countDocuments({}); + } + + /** Most-recent claims, for admin observability. */ + async listRecent(limit: number): Promise { + return this.collection + .find({}) + .sort({ awardedAt: -1 }) + .limit(Math.max(1, Math.min(limit, 500))) + .toArray(); + } +} diff --git a/ornn-api/src/domains/launchPromo/routes.ts b/ornn-api/src/domains/launchPromo/routes.ts new file mode 100644 index 00000000..8f0e4598 --- /dev/null +++ b/ornn-api/src/domains/launchPromo/routes.ts @@ -0,0 +1,136 @@ +/** + * Launch-promo HTTP routes (#724). + * + * GET /me/launch-promo — caller's claim status + * POST /admin/launch-promo/award/:userId — admin manually award a user + * GET /admin/launch-promo/recent — admin observability + * + * The cron-poll endpoint will land in a follow-up PR with the GitHub + * stargazers + NyxID GH-login pieces. The manual admin endpoint is + * enough to honour the launch-promo promise today. + * + * @module domains/launchPromo/routes + */ + +import { Hono } from "hono"; +import { + type AuthVariables, + nyxidAuthMiddleware, + getAuth, + requirePermission, +} from "../../middleware/nyxidAuth"; +import { AppError } from "../../shared/types/index"; +import { createLogger } from "../../shared/logger"; +import type { LaunchPromoService } from "./service"; +import { LAUNCH_PROMO_ERROR_PREFIXES } from "./service"; + +const logger = createLogger("launchPromoRoutes"); + +export interface LaunchPromoRoutesConfig { + service: LaunchPromoService; +} + +/** + * Translate service-layer error sentinels into the right HTTP status + + * AppError code. Kept here (route-layer) so the service stays free of + * HTTP concerns. + */ +function mapServiceError(err: unknown): AppError { + const msg = err instanceof Error ? err.message : String(err); + for (const prefix of LAUNCH_PROMO_ERROR_PREFIXES) { + if (msg.startsWith(`${prefix}:`)) { + switch (prefix) { + case "PROMO_DISABLED": + return AppError.badRequest("PROMO_DISABLED", msg); + case "ALREADY_CLAIMED": + return AppError.conflict("ALREADY_CLAIMED", msg); + case "RANK_EXCEEDED": + return AppError.forbidden("RANK_EXCEEDED", msg); + case "SLOTS_EXHAUSTED": + return AppError.conflict("SLOTS_EXHAUSTED", msg); + case "USER_NOT_FOUND": + return AppError.notFound("USER_NOT_FOUND", msg); + } + } + } + // Unmapped — bubble as 500 via the global error middleware. + return AppError.internalError("LAUNCH_PROMO_ERROR", msg); +} + +export function createLaunchPromoRoutes( + config: LaunchPromoRoutesConfig, +): Hono<{ Variables: AuthVariables }> { + const { service } = config; + const app = new Hono<{ Variables: AuthVariables }>(); + const auth = nyxidAuthMiddleware(); + + // ---- Caller-scoped -------------------------------------------------- + + app.get("/me/launch-promo", auth, async (c) => { + const { userId } = getAuth(c); + const status = await service.getStatusForUser(userId); + return c.json({ data: status, error: null }); + }); + + // ---- Admin --------------------------------------------------------- + + app.post( + "/admin/launch-promo/award/:userId", + auth, + requirePermission("ornn:admin:skill"), + async (c) => { + const targetUserId = c.req.param("userId"); + const { userId: adminId } = getAuth(c); + try { + const result = await service.awardUser({ + userId: targetUserId, + awardedBy: adminId, + }); + logger.info( + { adminId, targetUserId, redemptionCodeId: result.claim.redemptionCodeId }, + "Launch-promo manual award succeeded", + ); + return c.json({ + data: { + claim: { + userId: result.claim._id, + eligibilityRank: result.claim.eligibilityRank, + redemptionCodeId: result.claim.redemptionCodeId, + redemptionCode: result.redemptionCode, + awardedAt: result.claim.awardedAt.toISOString(), + awardedBy: result.claim.awardedBy, + }, + }, + error: null, + }); + } catch (err) { + throw mapServiceError(err); + } + }, + ); + + app.get( + "/admin/launch-promo/recent", + auth, + requirePermission("ornn:admin:skill"), + async (c) => { + const limit = Math.max(1, Math.min(500, Number(c.req.query("limit") ?? 50) || 50)); + const items = await service.repoListRecent(limit); + return c.json({ + data: { + items: items.map((c) => ({ + userId: c._id, + eligibilityRank: c.eligibilityRank, + redemptionCodeId: c.redemptionCodeId, + awardedAt: c.awardedAt.toISOString(), + awardedBy: c.awardedBy, + githubLogin: c.githubLogin ?? null, + })), + }, + error: null, + }); + }, + ); + + return app; +} diff --git a/ornn-api/src/domains/launchPromo/service.test.ts b/ornn-api/src/domains/launchPromo/service.test.ts new file mode 100644 index 00000000..8ad26ddd --- /dev/null +++ b/ornn-api/src/domains/launchPromo/service.test.ts @@ -0,0 +1,250 @@ +/** + * Tests for #724: LaunchPromoService.awardUser orchestrates the + * eligibility gate, redemption-code mint, notification drop, and + * claim insert as one atomic-ish unit. Cover the happy path + every + * service-level error sentinel so the route layer's HTTP mapping + * stays correct. + */ + +import { beforeEach, describe, expect, it } from "bun:test"; +import { LaunchPromoService, LAUNCH_PROMO_ERROR_PREFIXES } from "./service"; +import type { LaunchPromoClaimDoc } from "./types"; +import type { LaunchPromoRepository } from "./repository"; +import type { UserDirectoryRepository } from "../users/repository"; +import type { SettingsService } from "../settings/types"; +import type { LaunchPromoSection } from "../settings/sections"; +import type { RedemptionCodeService } from "../redemption-codes/service"; +import type { NotificationRepository } from "../notifications/repository"; + +const DEFAULT_SECTION: LaunchPromoSection = { + enabled: true, + repoOwner: "ChronoAIProject", + repoName: "Ornn", + totalSlots: 500, + awardPlayground: 200, + awardSkillGen: 200, + pollIntervalMs: 600_000, + codeExpiryDays: 90, + nyxidInviteCode: "NYX-TEST-123", +}; + +function makeService(opts: { + section?: Partial; + hasClaimed?: boolean; + rank?: number | null; + awarded?: number; + mintShouldFail?: boolean; + insertShouldFail?: "duplicate" | "other" | undefined; +}): { + service: LaunchPromoService; + claims: LaunchPromoClaimDoc[]; + notifications: Array<{ userId: string; title: string }>; + mintCalls: Array<{ grants: unknown }>; +} { + const merged: LaunchPromoSection = { ...DEFAULT_SECTION, ...(opts.section ?? {}) }; + const claims: LaunchPromoClaimDoc[] = []; + const notifications: Array<{ userId: string; title: string }> = []; + const mintCalls: Array<{ grants: unknown }> = []; + + const repo: LaunchPromoRepository = { + ensureIndexes: async () => {}, + hasClaimed: async () => opts.hasClaimed ?? false, + findByUserId: async (id) => claims.find((c) => c._id === id) ?? null, + insert: async (doc) => { + if (opts.insertShouldFail === "duplicate") { + const err: Error & { code?: number } = new Error("dup"); + err.code = 11000; + throw err; + } + if (opts.insertShouldFail === "other") { + throw new Error("mongo died"); + } + claims.push(doc); + }, + countAwarded: async () => opts.awarded ?? 0, + listRecent: async () => claims.slice(), + } as unknown as LaunchPromoRepository; + + // `??` collapses both `undefined` and `null` to the default, but the + // null case is meaningful here (user not in directory). Use an + // explicit-key check so `rank: null` propagates as null. + const rankValue: number | null = "rank" in opts ? (opts.rank ?? null) : 42; + const userDirectoryRepo: UserDirectoryRepository = { + getRegistrationRank: async () => rankValue, + } as unknown as UserDirectoryRepository; + + const settingsService: SettingsService = { + getLaunchPromo: async () => merged, + } as unknown as SettingsService; + + const redemptionCodeService: RedemptionCodeService = { + mint: async (p: { grants: unknown }) => { + mintCalls.push({ grants: p.grants }); + if (opts.mintShouldFail) throw new Error("mint blew up"); + return { + _id: "code-id-1", + code: "LAUNCH-PROMO-TEST", + grants: p.grants, + createdAt: new Date(), + createdBy: { userId: "x", email: "x", displayName: "x" }, + expiresAt: new Date(Date.now() + 86400_000), + status: "active", + } as never; + }, + } as unknown as RedemptionCodeService; + + const notificationRepo: NotificationRepository = { + create: async (input) => { + notifications.push({ userId: input.userId, title: input.title }); + return { _id: "n1", ...input, data: input.data ?? {}, readAt: null, createdAt: new Date() } as never; + }, + } as unknown as NotificationRepository; + + const service = new LaunchPromoService({ + repo, + userDirectoryRepo, + settingsService, + redemptionCodeService, + notificationRepo, + }); + return { service, claims, notifications, mintCalls }; +} + +describe("LaunchPromoService.awardUser", () => { + let now: Date; + beforeEach(() => { + now = new Date(); + void now; + }); + + it("happy path: mints code + records claim + drops notification", async () => { + const fx = makeService({ rank: 7, awarded: 3 }); + const out = await fx.service.awardUser({ userId: "u-7", awardedBy: "admin-1" }); + + expect(out.claim._id).toBe("u-7"); + expect(out.claim.eligibilityRank).toBe(7); + expect(out.claim.redemptionCodeId).toBe("code-id-1"); + expect(out.claim.awardedBy).toBe("admin-1"); + expect(out.redemptionCode).toBe("LAUNCH-PROMO-TEST"); + expect(fx.claims).toHaveLength(1); + expect(fx.notifications).toHaveLength(1); + expect(fx.notifications[0]!.userId).toBe("u-7"); + expect(fx.notifications[0]!.title).toContain("LAUNCH-PROMO-TEST"); + expect(fx.mintCalls[0]!.grants).toEqual([ + { surface: "playground", amount: 200 }, + { surface: "skillGen", amount: 200 }, + ]); + }); + + it("PROMO_DISABLED when section.enabled is false", async () => { + const fx = makeService({ section: { enabled: false } }); + await expect(fx.service.awardUser({ userId: "u", awardedBy: "a" })).rejects.toThrow( + /^PROMO_DISABLED:/, + ); + expect(fx.claims).toHaveLength(0); + expect(fx.notifications).toHaveLength(0); + }); + + it("PROMO_DISABLED when both grant amounts are zero", async () => { + const fx = makeService({ section: { awardPlayground: 0, awardSkillGen: 0 } }); + await expect(fx.service.awardUser({ userId: "u", awardedBy: "a" })).rejects.toThrow( + /^PROMO_DISABLED:/, + ); + }); + + it("ALREADY_CLAIMED short-circuits before mint", async () => { + const fx = makeService({ hasClaimed: true }); + await expect(fx.service.awardUser({ userId: "u", awardedBy: "a" })).rejects.toThrow( + /^ALREADY_CLAIMED:/, + ); + expect(fx.mintCalls).toHaveLength(0); + }); + + it("USER_NOT_FOUND when directory has no rank", async () => { + const fx = makeService({ rank: null }); + await expect(fx.service.awardUser({ userId: "u", awardedBy: "a" })).rejects.toThrow( + /^USER_NOT_FOUND:/, + ); + }); + + it("RANK_EXCEEDED when user rank is past totalSlots", async () => { + const fx = makeService({ rank: 600 }); // > totalSlots 500 + await expect(fx.service.awardUser({ userId: "u", awardedBy: "a" })).rejects.toThrow( + /^RANK_EXCEEDED:/, + ); + }); + + it("SLOTS_EXHAUSTED when awarded already met totalSlots", async () => { + const fx = makeService({ rank: 1, awarded: 500 }); + await expect(fx.service.awardUser({ userId: "u", awardedBy: "a" })).rejects.toThrow( + /^SLOTS_EXHAUSTED:/, + ); + }); + + it("duplicate-key race during insert maps to ALREADY_CLAIMED", async () => { + const fx = makeService({ rank: 5, insertShouldFail: "duplicate" }); + await expect(fx.service.awardUser({ userId: "u", awardedBy: "a" })).rejects.toThrow( + /^ALREADY_CLAIMED:/, + ); + }); + + it("notification failure does NOT throw — claim still recorded", async () => { + const fx = makeService({ rank: 5 }); + // Sabotage notification repo with a throwing create. + (fx as never as { /* hack: replace */ }) // not used + const svc = new LaunchPromoService({ + repo: { + ensureIndexes: async () => {}, + hasClaimed: async () => false, + findByUserId: async () => null, + insert: async (doc) => fx.claims.push(doc), + countAwarded: async () => 0, + listRecent: async () => fx.claims.slice(), + } as unknown as LaunchPromoRepository, + userDirectoryRepo: { getRegistrationRank: async () => 5 } as unknown as UserDirectoryRepository, + settingsService: { getLaunchPromo: async () => DEFAULT_SECTION } as unknown as SettingsService, + redemptionCodeService: { + mint: async () => ({ _id: "c", code: "X", grants: [], createdAt: new Date(), createdBy: {} as never, expiresAt: new Date(), status: "active" } as never), + } as unknown as RedemptionCodeService, + notificationRepo: { + create: async () => { throw new Error("notif down"); }, + } as unknown as NotificationRepository, + }); + const out = await svc.awardUser({ userId: "u", awardedBy: "a" }); + expect(out.claim).toBeTruthy(); + expect(fx.claims).toHaveLength(1); + }); + + it("error sentinels list stays exhaustive", () => { + expect(LAUNCH_PROMO_ERROR_PREFIXES).toEqual([ + "PROMO_DISABLED", + "RANK_EXCEEDED", + "SLOTS_EXHAUSTED", + "ALREADY_CLAIMED", + "USER_NOT_FOUND", + ]); + }); +}); + +describe("LaunchPromoService.getStatusForUser", () => { + it("composes promo enabled + claim + rank + slots remaining", async () => { + const fx = makeService({ rank: 12, awarded: 100 }); + const status = await fx.service.getStatusForUser("u-12"); + expect(status).toEqual({ + promoEnabled: true, + claimed: false, + rank: 12, + totalSlots: 500, + slotsRemaining: 400, + awardedAt: null, + }); + }); + + it("claimed=true with awardedAt set when user has claim doc", async () => { + const fx = makeService({ rank: 4 }); + await fx.service.awardUser({ userId: "u-4", awardedBy: "admin" }); + const status = await fx.service.getStatusForUser("u-4"); + expect(status.claimed).toBe(true); + expect(status.awardedAt).not.toBeNull(); + }); +}); diff --git a/ornn-api/src/domains/launchPromo/service.ts b/ornn-api/src/domains/launchPromo/service.ts new file mode 100644 index 00000000..93b51433 --- /dev/null +++ b/ornn-api/src/domains/launchPromo/service.ts @@ -0,0 +1,227 @@ +/** + * Launch-promo service (#724) — eligibility + award orchestration. + * + * Public surface: + * + * - `getStatusForUser(userId)` — compose the `/me/launch-promo` + * response (promo on/off, claimed?, rank, slots remaining). + * - `awardUser({ userId, awardedBy, githubLogin? })` — the single + * award flow: gate on enabled + rank ≤ totalSlots + slot + * availability + not-already-claimed, mint a redemption code via + * the redemption-codes domain, drop a `launchPromo.codeDelivered` + * notification carrying the code, and record the claim. + * + * Idempotent: a second `awardUser` for the same user short-circuits + * on the claim-row primary-key check. Race-safe: the claim insert + * uses `_id = userId` so a duplicate-key error cleanly resolves the + * "two callers tried to award the same user at the same instant" + * case (one wins, the other gets `ALREADY_CLAIMED`). + * + * Out of scope here (follow-up PR): GitHub stargazers polling, the + * cron loop, and the NyxID → GitHub login lookup. This service exposes + * `awardUser` cleanly so the cron just calls it once it knows who + * starred. + * + * @module domains/launchPromo/service + */ + +import type { LaunchPromoRepository } from "./repository"; +import type { LaunchPromoClaimDoc, LaunchPromoStatus } from "./types"; +import type { UserDirectoryRepository } from "../users/repository"; +import type { SettingsService } from "../settings/types"; +import type { RedemptionCodeService } from "../redemption-codes/service"; +import type { NotificationRepository } from "../notifications/repository"; +import { createLogger } from "../../shared/logger"; + +const logger = createLogger("launchPromoService"); + +/** Sentinel `awardedBy` value the cron job uses; differentiates from + * human admin user-ids in the claim audit trail. */ +export const CRON_ACTOR = "system:cron"; + +export const LAUNCH_PROMO_ERROR_PREFIXES = [ + "PROMO_DISABLED", + "RANK_EXCEEDED", + "SLOTS_EXHAUSTED", + "ALREADY_CLAIMED", + "USER_NOT_FOUND", +] as const; + +export interface AwardUserParams { + userId: string; + awardedBy: string; + /** Known when the cron matched the user via stargazer list. Stored + * on the claim doc for the audit trail. */ + githubLogin?: string; +} + +export interface AwardUserResult { + claim: LaunchPromoClaimDoc; + /** The minted redemption code string — caller decides whether to + * surface in the notification body. */ + redemptionCode: string; +} + +export interface LaunchPromoServiceDeps { + repo: LaunchPromoRepository; + userDirectoryRepo: UserDirectoryRepository; + settingsService: SettingsService; + redemptionCodeService: RedemptionCodeService; + notificationRepo: NotificationRepository; +} + +export class LaunchPromoService { + private readonly repo: LaunchPromoRepository; + private readonly userDirectoryRepo: UserDirectoryRepository; + private readonly settingsService: SettingsService; + private readonly redemptionCodeService: RedemptionCodeService; + private readonly notificationRepo: NotificationRepository; + + constructor(deps: LaunchPromoServiceDeps) { + this.repo = deps.repo; + this.userDirectoryRepo = deps.userDirectoryRepo; + this.settingsService = deps.settingsService; + this.redemptionCodeService = deps.redemptionCodeService; + this.notificationRepo = deps.notificationRepo; + } + + /** Pass-through for the admin observability endpoint. */ + async repoListRecent(limit: number): Promise { + return this.repo.listRecent(limit); + } + + async getStatusForUser(userId: string): Promise { + const [section, rank, awarded, claim] = await Promise.all([ + this.settingsService.getLaunchPromo(), + this.userDirectoryRepo.getRegistrationRank(userId), + this.repo.countAwarded(), + this.repo.findByUserId(userId), + ]); + + return { + promoEnabled: section.enabled, + claimed: !!claim, + rank, + totalSlots: section.totalSlots, + slotsRemaining: Math.max(0, section.totalSlots - awarded), + awardedAt: claim ? claim.awardedAt.toISOString() : null, + }; + } + + async awardUser(params: AwardUserParams): Promise { + const section = await this.settingsService.getLaunchPromo(); + if (!section.enabled) { + throw new Error("PROMO_DISABLED: launch promo is not enabled"); + } + + // Idempotency: short-circuit on existing claim row before any + // expensive lookup / mint. + if (await this.repo.hasClaimed(params.userId)) { + throw new Error(`ALREADY_CLAIMED: user '${params.userId}' has already claimed the launch promo`); + } + + const rank = await this.userDirectoryRepo.getRegistrationRank(params.userId); + if (rank === null) { + throw new Error(`USER_NOT_FOUND: user '${params.userId}' is not in the directory`); + } + if (rank > section.totalSlots) { + throw new Error( + `RANK_EXCEEDED: user rank ${rank} is past the ${section.totalSlots}-slot cap`, + ); + } + + const awarded = await this.repo.countAwarded(); + if (awarded >= section.totalSlots) { + throw new Error( + `SLOTS_EXHAUSTED: ${awarded}/${section.totalSlots} slots already awarded`, + ); + } + + // Mint a redemption code that the user redeems themselves through + // the existing /me/redeem UI. The promised "delivered within 24h" + // is satisfied by the notification we drop below. + const grants: Array<{ surface: "playground" | "skillGen"; amount: number }> = []; + if (section.awardPlayground > 0) { + grants.push({ surface: "playground", amount: section.awardPlayground }); + } + if (section.awardSkillGen > 0) { + grants.push({ surface: "skillGen", amount: section.awardSkillGen }); + } + if (grants.length === 0) { + // Misconfiguration: enabled with both grants = 0. Don't mint a + // useless code. + throw new Error("PROMO_DISABLED: launch promo has zero grants configured"); + } + + const expiresAt = new Date( + Date.now() + section.codeExpiryDays * 24 * 60 * 60 * 1000, + ); + const codeDoc = await this.redemptionCodeService.mint({ + admin: { userId: "system:launchPromo", email: "launch-promo@ornn", displayName: "Launch Promo" }, + grants, + note: `launch-promo award for ${params.userId} (rank ${rank})`, + expiresAt, + }); + + // Record the claim BEFORE the notification so a notification + // failure can't leave us in "code minted but no claim row" state + // that would let a retry double-mint. + const claim: LaunchPromoClaimDoc = { + _id: params.userId, + eligibilityRank: rank, + redemptionCodeId: codeDoc._id, + awardedAt: new Date(), + awardedBy: params.awardedBy, + ...(params.githubLogin ? { githubLogin: params.githubLogin } : {}), + }; + try { + await this.repo.insert(claim); + } catch (err) { + const code = (err as { code?: number }).code; + if (code === 11000) { + // Race: someone else awarded in between our two queries. + throw new Error(`ALREADY_CLAIMED: user '${params.userId}' claim landed in a race`); + } + throw err; + } + + // Best-effort notification — claim is already recorded, so a + // notification failure doesn't cost the user the grant. Admins can + // resend via the notifications UI later. + try { + await this.notificationRepo.create({ + userId: params.userId, + category: "launchPromo.codeDelivered", + title: `Your launch promo is ready: ${codeDoc.code}`, + body: [ + `You're in the first ${section.totalSlots} Ornn users — thank you for the early support!`, + ``, + `Redeem the code below in Settings → Redeem to add ${section.awardPlayground} Playground + ${section.awardSkillGen} Skill Generation credits to your account:`, + ``, + ` ${codeDoc.code}`, + ``, + section.nyxidInviteCode + ? `The promo also bundles a NyxID invite code: ${section.nyxidInviteCode}` + : "", + ] + .filter((line) => line !== "") + .join("\n"), + link: "/settings#redeem", + data: { + redemptionCodeId: codeDoc._id, + redemptionCode: codeDoc.code, + nyxidInviteCode: section.nyxidInviteCode || null, + awardPlayground: section.awardPlayground, + awardSkillGen: section.awardSkillGen, + }, + }); + } catch (err) { + logger.warn( + { userId: params.userId, err: (err as Error).message }, + "Launch-promo notification delivery failed — claim is recorded", + ); + } + + return { claim, redemptionCode: codeDoc.code }; + } +} diff --git a/ornn-api/src/domains/launchPromo/types.ts b/ornn-api/src/domains/launchPromo/types.ts new file mode 100644 index 00000000..3f0208e6 --- /dev/null +++ b/ornn-api/src/domains/launchPromo/types.ts @@ -0,0 +1,49 @@ +/** + * Launch-promo domain types (#724). + * + * One claim doc per Ornn user that's been awarded the launch-promo + * grant. Append-only: a user is either present (already awarded, code + * delivered) or absent. The doc id is the Ornn user id so the + * idempotency gate is a single `findOne` on a primary-key lookup; no + * scan needed. + * + * @module domains/launchPromo/types + */ + +export interface LaunchPromoClaimDoc { + /** Ornn user id (NyxID user.userId). Primary key. */ + _id: string; + /** Cached Ornn registration rank when the claim was awarded + * (1-based; 1 == the very first Ornn user). Stored so an admin + * audit can answer "why was this user eligible" without re-running + * the rank query. */ + eligibilityRank: number; + /** Redemption-codes domain id of the minted code (admin can pull + * the actual code string via that id). */ + redemptionCodeId: string; + /** UTC timestamp of the award. */ + awardedAt: Date; + /** Who triggered the award — admin user id for manual flows, + * `"system:cron"` for the GH stargazers cron loop. */ + awardedBy: string; + /** GitHub login at award time, when known. Optional — the cron + * path populates it (it knows: that's how it matched the user); + * the admin manual-award path may not. */ + githubLogin?: string; +} + +/** Caller-facing status for `GET /me/launch-promo`. */ +export interface LaunchPromoStatus { + /** Whether the promo section is enabled in admin settings. */ + promoEnabled: boolean; + /** Whether the caller has already claimed (and code was delivered). */ + claimed: boolean; + /** Caller's 1-based Ornn registration rank, or null if unknown. */ + rank: number | null; + /** Total slots configured (e.g. 500). */ + totalSlots: number; + /** Slots remaining (totalSlots - awarded count). */ + slotsRemaining: number; + /** ISO timestamp of the claim, if claimed. */ + awardedAt: string | null; +} diff --git a/ornn-api/src/domains/notifications/bootstrap.ts b/ornn-api/src/domains/notifications/bootstrap.ts index 19b65eed..946f421c 100644 --- a/ornn-api/src/domains/notifications/bootstrap.ts +++ b/ornn-api/src/domains/notifications/bootstrap.ts @@ -26,6 +26,9 @@ import type { BroadcastRepository } from "../broadcasts/repository"; export interface NotificationsWiring { readonly service: NotificationService; readonly routes: Hono<{ Variables: AuthVariables }>; + /** Exposed so other domains (e.g. launch-promo) can publish per-user + * notifications without re-instantiating the repo. */ + readonly repo: NotificationRepository; } export async function wireNotifications(deps: { @@ -55,5 +58,5 @@ export async function wireNotifications(deps: { broadcastRepo: deps.broadcastRepo, }); const routes = createNotificationRoutes({ notificationService: service }); - return { service, routes }; + return { service, routes, repo }; } diff --git a/ornn-api/src/domains/notifications/types.ts b/ornn-api/src/domains/notifications/types.ts index c8d9b1dc..2c533249 100644 --- a/ornn-api/src/domains/notifications/types.ts +++ b/ornn-api/src/domains/notifications/types.ts @@ -23,7 +23,8 @@ export type NotificationCategory = | "audit.completed" | "audit.risky_for_consumer" - | "quota.credits_granted"; + | "quota.credits_granted" + | "launchPromo.codeDelivered"; export interface NotificationDocument { readonly _id: string; diff --git a/ornn-api/src/domains/settings/sections/index.ts b/ornn-api/src/domains/settings/sections/index.ts index 3909a3a7..1de360cd 100644 --- a/ornn-api/src/domains/settings/sections/index.ts +++ b/ornn-api/src/domains/settings/sections/index.ts @@ -16,6 +16,7 @@ import { skillAuditSection, type SkillAuditSection } from "./skillAudit"; import { skillGenSection, type SkillGenSection } from "./skillGen"; import { telemetrySection, type TelemetrySection } from "./telemetry"; import { extrasSection, type ExtrasSection } from "./extras"; +import { launchPromoSection, type LaunchPromoSection } from "./launchPromo"; export { mirrorSection, @@ -25,6 +26,7 @@ export { skillGenSection, telemetrySection, extrasSection, + launchPromoSection, }; export type { @@ -35,6 +37,7 @@ export type { SkillGenSection, TelemetrySection, ExtrasSection, + LaunchPromoSection, }; export type SectionId = @@ -44,7 +47,8 @@ export type SectionId = | "nyxid" | "skillAudit" | "telemetry" - | "extras"; + | "extras" + | "launchPromo"; export interface SectionMeta { /** Stable section id, also the Mongo `_id` of the section row. */ @@ -67,4 +71,5 @@ export const sections = { skillAudit: skillAuditSection, telemetry: telemetrySection, extras: extrasSection, + launchPromo: launchPromoSection, } as const; diff --git a/ornn-api/src/domains/settings/sections/launchPromo.ts b/ornn-api/src/domains/settings/sections/launchPromo.ts new file mode 100644 index 00000000..6fc4926d --- /dev/null +++ b/ornn-api/src/domains/settings/sections/launchPromo.ts @@ -0,0 +1,81 @@ +/** + * Launch-promo section schema (#724). + * + * Drives the GitHub-star → Ornn-credit promo announced on the landing / + * news page. The cron job (and admin manual-award endpoint) read this + * section to decide whether the promo is active, where to look for + * stargazers, how many slots are still available, and what the per-claim + * grants are. + * + * Defaults are deliberately conservative: `enabled: false` and zero + * grants. An admin has to opt in + configure the slot count + grant + * amounts explicitly before any claim can land. + * + * @module domains/settings/sections/launchPromo + */ + +import { z } from "zod"; +import type { SectionMeta } from "./index"; + +/** GitHub `owner/repo` slug regex. */ +const REPO_SEGMENT_RE = /^[A-Za-z0-9._-]{1,100}$/; + +export const launchPromoSchema = z.object({ + enabled: z.boolean(), + /** GitHub repo owner (login). */ + repoOwner: z.string().regex(REPO_SEGMENT_RE).or(z.literal("")), + /** GitHub repo name. */ + repoName: z.string().regex(REPO_SEGMENT_RE).or(z.literal("")), + /** + * Maximum number of Ornn users that can ever claim this promo. The + * service refuses to award if `claimed >= totalSlots`. Per the design + * decision: "first 500 by Ornn registration order". + */ + totalSlots: z.number().int().min(0).max(100000), + /** Per-claim grant — Playground surface (monthly credits). */ + awardPlayground: z.number().int().min(0).max(1_000_000), + /** Per-claim grant — Skill Generation surface. */ + awardSkillGen: z.number().int().min(0).max(1_000_000), + /** + * Cron poll interval. Set to 0 to disable the auto-poll loop entirely + * (admin still gets the manual award endpoints). 5–10 min is the + * sweet spot per the #724 design call. + */ + pollIntervalMs: z.number().int().min(0).max(24 * 60 * 60 * 1000), + /** + * Days a minted launch-promo redemption code stays valid before + * expiry. The promo announcement promises "delivered within 24h"; the + * code itself sticks around longer so users can redeem at their + * leisure. + */ + codeExpiryDays: z.number().int().min(1).max(365), + /** + * Static NyxID invite code shown alongside the Ornn redemption code + * in the per-claim notification body. Mirrors the code printed on + * landing/news. Editable here so a rotation doesn't require a + * redeploy. + */ + nyxidInviteCode: z.string().max(64).or(z.literal("")), +}); + +export type LaunchPromoSection = z.infer; + +export const launchPromoDefaults: LaunchPromoSection = { + enabled: false, + repoOwner: "", + repoName: "", + totalSlots: 500, + awardPlayground: 200, + awardSkillGen: 200, + pollIntervalMs: 10 * 60 * 1000, + codeExpiryDays: 90, + nyxidInviteCode: "", +}; + +export const launchPromoSection: SectionMeta = { + id: "launchPromo", + publicPath: "launch-promo", + schema: launchPromoSchema, + secretFields: [], + defaults: launchPromoDefaults, +}; diff --git a/ornn-api/src/domains/settings/service.ts b/ornn-api/src/domains/settings/service.ts index d7ac959c..6a6944e7 100644 --- a/ornn-api/src/domains/settings/service.ts +++ b/ornn-api/src/domains/settings/service.ts @@ -32,6 +32,7 @@ import type { SettingsRepository } from "./repository"; import { sections, type ExtrasSection, + type LaunchPromoSection, type MirrorSection, type NyxidSection, type PlaygroundSection, @@ -113,6 +114,9 @@ export class SettingsServiceImpl implements SettingsService { async getExtras(): Promise { return this.getSection("extras"); } + async getLaunchPromo(): Promise { + return this.getSection("launchPromo"); + } async getSection(id: SectionId): Promise { const cached = this.cache.get(id); diff --git a/ornn-api/src/domains/settings/types.ts b/ornn-api/src/domains/settings/types.ts index dd08eb31..2cf2e4b4 100644 --- a/ornn-api/src/domains/settings/types.ts +++ b/ornn-api/src/domains/settings/types.ts @@ -14,6 +14,7 @@ import type { ExtrasSection, + LaunchPromoSection, MirrorSection, NyxidSection, PlaygroundSection, @@ -56,6 +57,7 @@ export interface SettingsService { getSkillAudit(): Promise; getTelemetry(): Promise; getExtras(): Promise; + getLaunchPromo(): Promise; /** * Read a section by id. Returns the typed payload, applying defaults diff --git a/ornn-api/src/domains/users/repository.ts b/ornn-api/src/domains/users/repository.ts index b31e0e44..c17874e0 100644 --- a/ornn-api/src/domains/users/repository.ts +++ b/ornn-api/src/domains/users/repository.ts @@ -287,6 +287,28 @@ export class UserDirectoryRepository { return this.collection.find(filter).limit(hardLimit).toArray(); } + /** + * Registration-rank lookup for the launch-promo eligibility gate + * (#724). Returns the 1-based position of `userId` in the ordering + * by `firstSeenAt` ascending — rank 1 == first user Ornn ever saw. + * Returns `null` when the user isn't in the directory. + * + * Two queries: one to load the target user's `firstSeenAt`, one to + * count users with an earlier timestamp. Both hit the primary key + * or a small filter — no full scan. + */ + async getRegistrationRank(userId: string): Promise { + const target = await this.collection.findOne( + { _id: userId }, + { projection: { firstSeenAt: 1 } }, + ); + if (!target) return null; + const earlierCount = await this.collection.countDocuments({ + firstSeenAt: { $lt: target.firstSeenAt }, + }); + return earlierCount + 1; + } + /** * Tile counts for the admin dashboard. Replaces the activity-derived * `getStats` from the old ActivityRepository.