Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/launch-promo-foundation-724.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 27 additions & 6 deletions ornn-api/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -470,12 +471,15 @@ export async function bootstrap(config: SkillConfig): Promise<BootstrapResult> {
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 });
Expand Down Expand Up @@ -607,10 +611,26 @@ export async function bootstrap(config: SkillConfig): Promise<BootstrapResult> {

// ---- 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
Expand Down Expand Up @@ -888,6 +908,7 @@ export async function bootstrap(config: SkillConfig): Promise<BootstrapResult> {
apiApp.route("/", mirrorRoutes);
apiApp.route("/", auditRoutes);
apiApp.route("/", notificationRoutes);
apiApp.route("/", launchPromoRoutes);
apiApp.route("/", announcementRoutes);
apiApp.route("/", broadcastRoutes);
apiApp.route("/", analyticsRoutes);
Expand Down
55 changes: 55 additions & 0 deletions ornn-api/src/domains/launchPromo/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -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<LaunchPromoWiring> {
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 };
}
84 changes: 84 additions & 0 deletions ornn-api/src/domains/launchPromo/repository.ts
Original file line number Diff line number Diff line change
@@ -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<LaunchPromoClaimDoc>;

constructor(db: Db) {
this.collection = db.collection<LaunchPromoClaimDoc>("launch_promo_claims");
}

async ensureIndexes(): Promise<void> {
// `_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<boolean> {
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<LaunchPromoClaimDoc | null> {
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<void> {
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<number> {
return this.collection.countDocuments({});
}

/** Most-recent claims, for admin observability. */
async listRecent(limit: number): Promise<LaunchPromoClaimDoc[]> {
return this.collection
.find({})
.sort({ awardedAt: -1 })
.limit(Math.max(1, Math.min(limit, 500)))
.toArray();
}
}
136 changes: 136 additions & 0 deletions ornn-api/src/domains/launchPromo/routes.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading