mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
* feat: add promotions — runtime-fetchable promotional offers
Adds a standalone promotions entity so time-boxed promotional offers
can be created, activated, and expired at runtime without shipping a
CLI release.
- promotions table: slug, display fields, draft/active/ended status,
time window, and a declarative CLI activation payload (provider,
authChoiceId, plugin names, model refs, signup/docs/launch URLs)
- public API: GET /api/v1/promotions (active, in-window only, cached)
and GET /api/v1/promotions/{slug} (hides drafts and pre-launch
activations; serves ended state)
- homepage: active promotions render as cards via a public
promotions.listActive query; section hidden when none are live
- admin writes via HTTP (POST create / {slug}/update / {slug}/status)
and Convex mutations, both admin-gated with audit log entries
- management dashboard: Promotions page (admin-only) to create, edit,
and activate/end promotions
- clawhub-admin CLI: promotions list/create/update/set-status
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: rebuild promotions form with proper labeled field grid
Replace the management search-row markup with Input/Textarea/Label UI
components in a dedicated responsive form grid (custom classes — the
legacy global .grid rule collides with Tailwind's grid utility).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: reject slug changes on non-draft promotions
Activated promotion slugs are referenced by external links and CLI
claim provenance; renaming them would break both. Drafts can still be
renamed freely.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: refresh promotions at lifecycle boundaries
* fix: preserve published promotion history
* fix: align promotion visibility boundaries
* fix: preserve promotion timestamp integrity
* fix: harden promotion editor rendering
* fix: vary promotion queries by current time
* fix: paginate promotion history safely
* fix: keep ended promotions terminal
* fix: share promotion discovery cache
* fix: bound active promotion reads
* fix: align active promotion limits
* feat: publish promotions as a hosted feed (clawhub-promotions)
Adds a third hosted feed so OpenClaw clients can discover active
promotions through the same immutable-snapshot pipeline as the plugin
and skills catalogs (ETag/304 revalidation, CDN cache headers), with a
client cache fully separate from update checks.
- packages/schema: promotionsFeed wire contract (schemaVersion 1,
deterministic serialization, window validation)
- convex/promotionsFeed.ts: publishInternal builds the snapshot from
active, launched promotions (same visibility rule as the public API)
and upserts the catalogFeedPublications row
- event-driven republication: promotions.update/setStatus schedule an
immediate republish plus runAt jobs at future window edges, so
activation, kill-switch, launch, and expiry all land without waiting
for a periodic publish
- GET /api/v1/feeds/promotions served through the shared feed handler;
vercel rewrites for /v1/feeds/promotions and /feeds/promotions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: hide pre-launch promotions on the slug endpoint regardless of status
A promotion activated and then killed before startsAt was publicly
readable by slug. Hide all non-draft promotions before their window
opens; ended promotions that did launch stay visible.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: publish promotions after expiry boundary
* fix: keep promotions feed publications fresh
* fix: initialize promotions feed safely
* fix: use deployable promotions function name
* fix: keep categorize dialog open while dismissing the categories dropdown
The categories dropdown was modal, which disables pointer events on the
rest of the page while open. The click that dismisses the dropdown then
targets <body>, which the parent Dialog treats as an outside interaction
and closes too — discarding unsaved category selections. Render the
dropdown non-modal so only it dismisses and Save keeps working.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: let plugin owners edit categories and topics after they are set
The categorize entry point vanished once metadata existed, leaving
owners no way to change categories or topics. Keep a compact owner-only
Edit control in the taxonomy row that reopens the categorize dialog.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: remove unrelated taxonomy changes
* fix: keep canceled promotions private
* fix: reject expired promotion launches
* fix: harden promotion input boundaries
* feat: enforce CLI authoring contracts on promotion writes
The OpenClaw consumer rejects promotions whose modelRef, provider, or
authChoiceId violate its shell-safe identifier grammars, skips aliases
that are not typed identifiers, and refuses model refs outside the
declared provider prefix — so a promotion authored with, say, a spaced
alias published cleanly and then silently degraded at claim time.
Validate all of it at the write path instead: shell-safe modelRef and
identifier grammars, typed-identifier aliases, <provider>/ model-ref
prefix when a provider is declared, and npm-safe plugin names via the
registry's canonical grammar (scoped @scope/name allowed). Update the
management form hint/placeholder to teach the alias contract.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* style: format promotions test
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
588 lines
20 KiB
TypeScript
588 lines
20 KiB
TypeScript
import { paginationOptsValidator } from "convex/server";
|
|
import { ConvexError, v } from "convex/values";
|
|
import { internal } from "./_generated/api";
|
|
import type { Doc, Id } from "./_generated/dataModel";
|
|
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
|
import { internalMutation, internalQuery, mutation, query } from "./functions";
|
|
import { assertAdmin, requireUser } from "./lib/access";
|
|
import { tryNormalizePackageName } from "./lib/packageRegistry";
|
|
|
|
export type PromotionStatus = "draft" | "active" | "ended";
|
|
|
|
const PROMOTION_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
// Cross-repo authoring contracts with the OpenClaw promotions consumer.
|
|
// The CLI rejects the entire promotion when modelRef / provider /
|
|
// authChoiceId violate these grammars (they are echoed into copy-paste
|
|
// commands, so anything else is a shell-injection path), and it skips
|
|
// aliases that are not typed identifiers — so reject at authoring time
|
|
// instead of publishing a promotion clients cannot claim. Plugin names use
|
|
// the registry's canonical npm-safe grammar (scoped names allowed).
|
|
const PROMOTION_MODEL_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/;
|
|
const PROMOTION_ALIAS_PATTERN = /^[A-Za-z0-9_.:-]+$/;
|
|
const PROMOTION_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._@/-]*$/;
|
|
const MAX_SLUG_LENGTH = 64;
|
|
const MAX_TITLE_LENGTH = 120;
|
|
const MAX_BLURB_LENGTH = 500;
|
|
const MAX_SHORT_FIELD_LENGTH = 128;
|
|
const MAX_URL_LENGTH = 500;
|
|
const MAX_MODELS = 20;
|
|
const MAX_PLUGIN_NAMES = 10;
|
|
const STAFF_LIST_PAGE_SIZE = 100;
|
|
const ACTIVE_SET_LIMIT = 50;
|
|
|
|
const promotionModelArgValidator = v.object({
|
|
modelRef: v.string(),
|
|
alias: v.optional(v.string()),
|
|
suggestedDefault: v.optional(v.boolean()),
|
|
});
|
|
|
|
const promotionInputArgs = {
|
|
slug: v.string(),
|
|
title: v.string(),
|
|
blurb: v.string(),
|
|
sponsor: v.optional(v.string()),
|
|
startsAt: v.number(),
|
|
endsAt: v.number(),
|
|
provider: v.optional(v.string()),
|
|
authChoiceId: v.optional(v.string()),
|
|
pluginNames: v.optional(v.array(v.string())),
|
|
models: v.array(promotionModelArgValidator),
|
|
signupUrl: v.optional(v.string()),
|
|
docsUrl: v.optional(v.string()),
|
|
launchPageUrl: v.optional(v.string()),
|
|
} as const;
|
|
|
|
const promotionStatusArgValidator = v.union(
|
|
v.literal("draft"),
|
|
v.literal("active"),
|
|
v.literal("ended"),
|
|
);
|
|
|
|
type PromotionModelInput = {
|
|
modelRef: string;
|
|
alias?: string;
|
|
suggestedDefault?: boolean;
|
|
};
|
|
|
|
export type PromotionInput = {
|
|
slug: string;
|
|
title: string;
|
|
blurb: string;
|
|
sponsor?: string;
|
|
startsAt: number;
|
|
endsAt: number;
|
|
provider?: string;
|
|
authChoiceId?: string;
|
|
pluginNames?: string[];
|
|
models: PromotionModelInput[];
|
|
signupUrl?: string;
|
|
docsUrl?: string;
|
|
launchPageUrl?: string;
|
|
};
|
|
|
|
export function normalizePromotionSlug(raw: string) {
|
|
return raw.trim().toLowerCase();
|
|
}
|
|
|
|
function requireShortField(label: string, value: string | undefined, maxLength: number) {
|
|
if (value === undefined) return undefined;
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return undefined;
|
|
if (trimmed.length > maxLength) {
|
|
throw new ConvexError(`${label} too long (max ${maxLength} chars)`);
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
function requireSingleLineField(label: string, value: string | undefined, maxLength: number) {
|
|
const parsed = requireShortField(label, value, maxLength);
|
|
if (parsed && /[\r\n]/.test(parsed)) {
|
|
throw new ConvexError(`${label} must be a single line`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function requireHttpsUrl(label: string, value: string | undefined) {
|
|
if (value === undefined) return undefined;
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return undefined;
|
|
if (trimmed.length > MAX_URL_LENGTH) {
|
|
throw new ConvexError(`${label} too long (max ${MAX_URL_LENGTH} chars)`);
|
|
}
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(trimmed);
|
|
} catch {
|
|
throw new ConvexError(`${label} must be a valid URL`);
|
|
}
|
|
if (parsed.protocol !== "https:") {
|
|
throw new ConvexError(`${label} must use https`);
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
export function normalizePromotionInput(input: PromotionInput): PromotionInput {
|
|
const slug = normalizePromotionSlug(input.slug);
|
|
if (!slug) throw new ConvexError("Slug required");
|
|
if (slug.length > MAX_SLUG_LENGTH || !PROMOTION_SLUG_PATTERN.test(slug)) {
|
|
throw new ConvexError(
|
|
"Slug must be lowercase letters, digits, and hyphens (no leading/trailing hyphen)",
|
|
);
|
|
}
|
|
|
|
const title = input.title.trim();
|
|
if (!title) throw new ConvexError("Title required");
|
|
if (title.length > MAX_TITLE_LENGTH) {
|
|
throw new ConvexError(`Title too long (max ${MAX_TITLE_LENGTH} chars)`);
|
|
}
|
|
|
|
const blurb = input.blurb.trim();
|
|
if (!blurb) throw new ConvexError("Blurb required");
|
|
if (blurb.length > MAX_BLURB_LENGTH) {
|
|
throw new ConvexError(`Blurb too long (max ${MAX_BLURB_LENGTH} chars)`);
|
|
}
|
|
|
|
if (
|
|
!Number.isFinite(input.startsAt) ||
|
|
!Number.isFinite(input.endsAt) ||
|
|
Number.isNaN(new Date(input.startsAt).getTime()) ||
|
|
Number.isNaN(new Date(input.endsAt).getTime())
|
|
) {
|
|
throw new ConvexError("startsAt and endsAt must be valid timestamps (ms)");
|
|
}
|
|
if (input.endsAt <= input.startsAt) {
|
|
throw new ConvexError("endsAt must be after startsAt");
|
|
}
|
|
|
|
if (input.models.length === 0) throw new ConvexError("At least one model required");
|
|
if (input.models.length > MAX_MODELS) {
|
|
throw new ConvexError(`Too many models (max ${MAX_MODELS})`);
|
|
}
|
|
const models = input.models.map((model) => {
|
|
const modelRef = requireSingleLineField("modelRef", model.modelRef, MAX_SHORT_FIELD_LENGTH * 2);
|
|
if (!modelRef) throw new ConvexError("modelRef required for every model");
|
|
if (!PROMOTION_MODEL_REF_PATTERN.test(modelRef)) {
|
|
throw new ConvexError(
|
|
`modelRef "${modelRef}" may only use letters, digits, and . _ : / - characters`,
|
|
);
|
|
}
|
|
const alias = requireSingleLineField("Model alias", model.alias, MAX_SHORT_FIELD_LENGTH);
|
|
if (alias && !PROMOTION_ALIAS_PATTERN.test(alias)) {
|
|
throw new ConvexError(
|
|
`Model alias "${alias}" must use only letters, digits, dots, underscores, colons, or dashes (no spaces) — the CLI skips aliases it cannot register`,
|
|
);
|
|
}
|
|
return {
|
|
modelRef,
|
|
...(alias ? { alias } : {}),
|
|
...(model.suggestedDefault ? { suggestedDefault: true } : {}),
|
|
};
|
|
});
|
|
|
|
const rawPluginNames = (input.pluginNames ?? []).map((name) => name.trim()).filter(Boolean);
|
|
if (rawPluginNames.length > MAX_PLUGIN_NAMES) {
|
|
throw new ConvexError(`Too many plugin names (max ${MAX_PLUGIN_NAMES})`);
|
|
}
|
|
const pluginNames = rawPluginNames.map((name) => {
|
|
if (name.length > 214) throw new ConvexError(`Plugin name too long: ${name}`);
|
|
const normalized = tryNormalizePackageName(name);
|
|
if (!normalized) {
|
|
throw new ConvexError(
|
|
"Plugin names must be lowercase and npm-safe (example: @scope/name or plugin-name)",
|
|
);
|
|
}
|
|
return normalized;
|
|
});
|
|
|
|
const sponsor = requireShortField("Sponsor", input.sponsor, MAX_SHORT_FIELD_LENGTH);
|
|
const provider = requireShortField("Provider", input.provider, MAX_SHORT_FIELD_LENGTH);
|
|
if (provider && !PROMOTION_IDENTIFIER_PATTERN.test(provider)) {
|
|
throw new ConvexError("Provider may only use letters, digits, and . _ @ / - characters");
|
|
}
|
|
const authChoiceId = requireShortField(
|
|
"authChoiceId",
|
|
input.authChoiceId,
|
|
MAX_SHORT_FIELD_LENGTH,
|
|
);
|
|
if (authChoiceId && !PROMOTION_IDENTIFIER_PATTERN.test(authChoiceId)) {
|
|
throw new ConvexError("authChoiceId may only use letters, digits, and . _ @ / - characters");
|
|
}
|
|
// The CLI refuses to configure models outside the promotion's declared
|
|
// provider (`<provider>/<model>`), so a mismatched ref is unclaimable.
|
|
if (provider) {
|
|
for (const model of models) {
|
|
const prefix = `${provider}/`;
|
|
if (!model.modelRef.startsWith(prefix) || model.modelRef.length <= prefix.length) {
|
|
throw new ConvexError(
|
|
`modelRef "${model.modelRef}" must start with the promotion provider prefix "${prefix}"`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
const signupUrl = requireHttpsUrl("signupUrl", input.signupUrl);
|
|
const docsUrl = requireHttpsUrl("docsUrl", input.docsUrl);
|
|
const launchPageUrl = requireHttpsUrl("launchPageUrl", input.launchPageUrl);
|
|
|
|
return {
|
|
slug,
|
|
title,
|
|
blurb,
|
|
...(sponsor ? { sponsor } : {}),
|
|
startsAt: input.startsAt,
|
|
endsAt: input.endsAt,
|
|
...(provider ? { provider } : {}),
|
|
...(authChoiceId ? { authChoiceId } : {}),
|
|
...(pluginNames.length > 0 ? { pluginNames } : {}),
|
|
models,
|
|
...(signupUrl ? { signupUrl } : {}),
|
|
...(docsUrl ? { docsUrl } : {}),
|
|
...(launchPageUrl ? { launchPageUrl } : {}),
|
|
};
|
|
}
|
|
|
|
export function isPromotionActive(promotion: Doc<"promotions">, now: number) {
|
|
return promotion.status === "active" && promotion.startsAt <= now && now <= promotion.endsAt;
|
|
}
|
|
|
|
function resolvePromotionLaunchedAt(
|
|
promotion: Pick<Doc<"promotions">, "status" | "startsAt" | "launchedAt">,
|
|
nextStatus: PromotionStatus,
|
|
nextStartsAt: number,
|
|
now: number,
|
|
) {
|
|
if (promotion.launchedAt !== undefined) return promotion.launchedAt;
|
|
if (promotion.status === "active" && promotion.startsAt <= now) return promotion.startsAt;
|
|
if (nextStatus === "active" && nextStartsAt <= now) return now;
|
|
return undefined;
|
|
}
|
|
|
|
export function toPublicPromotion(promotion: Doc<"promotions">, now: number) {
|
|
return {
|
|
slug: promotion.slug,
|
|
title: promotion.title,
|
|
blurb: promotion.blurb,
|
|
...(promotion.sponsor ? { sponsor: promotion.sponsor } : {}),
|
|
status: promotion.status,
|
|
active: isPromotionActive(promotion, now),
|
|
startsAt: promotion.startsAt,
|
|
endsAt: promotion.endsAt,
|
|
...(promotion.provider ? { provider: promotion.provider } : {}),
|
|
...(promotion.authChoiceId ? { authChoiceId: promotion.authChoiceId } : {}),
|
|
...(promotion.pluginNames && promotion.pluginNames.length > 0
|
|
? { pluginNames: promotion.pluginNames }
|
|
: {}),
|
|
models: promotion.models,
|
|
...(promotion.signupUrl ? { signupUrl: promotion.signupUrl } : {}),
|
|
...(promotion.docsUrl ? { docsUrl: promotion.docsUrl } : {}),
|
|
...(promotion.launchPageUrl ? { launchPageUrl: promotion.launchPageUrl } : {}),
|
|
};
|
|
}
|
|
|
|
async function getPromotionBySlug(ctx: QueryCtx | MutationCtx, slug: string) {
|
|
return await ctx.db
|
|
.query("promotions")
|
|
.withIndex("by_slug", (q) => q.eq("slug", slug))
|
|
.unique();
|
|
}
|
|
|
|
// Feed snapshots are immutable, so visibility changes must republish the
|
|
// promotions feed: immediately for the mutation itself, and at future window
|
|
// edges so launches and expiries land on time without waiting for the
|
|
// periodic backstop publish.
|
|
async function schedulePromotionsFeedRepublication(
|
|
ctx: MutationCtx,
|
|
promotion: Pick<Doc<"promotions">, "status" | "startsAt" | "endsAt">,
|
|
) {
|
|
await ctx.scheduler.runAfter(0, internal.promotionsFeed.publishInternal, {});
|
|
if (promotion.status !== "active") return;
|
|
const now = Date.now();
|
|
for (const edge of [promotion.startsAt, promotion.endsAt + 1]) {
|
|
if (edge > now) {
|
|
await ctx.scheduler.runAt(edge, internal.promotionsFeed.publishInternal, {});
|
|
}
|
|
}
|
|
}
|
|
|
|
async function requireActorFromId(ctx: MutationCtx, actorUserId: Id<"users">) {
|
|
const actor = await ctx.db.get(actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new Error("User not found");
|
|
return actor;
|
|
}
|
|
|
|
async function createPromotionForActor(
|
|
ctx: MutationCtx,
|
|
actor: Doc<"users">,
|
|
input: PromotionInput,
|
|
) {
|
|
assertAdmin(actor);
|
|
const normalized = normalizePromotionInput(input);
|
|
const existing = await getPromotionBySlug(ctx, normalized.slug);
|
|
if (existing) throw new ConvexError(`Promotion already exists: ${normalized.slug}`);
|
|
|
|
const now = Date.now();
|
|
const promotionId = await ctx.db.insert("promotions", {
|
|
...normalized,
|
|
status: "draft",
|
|
createdByUserId: actor._id,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: actor._id,
|
|
action: "promotion.create",
|
|
targetType: "promotion",
|
|
targetId: normalized.slug,
|
|
metadata: { promotionId, title: normalized.title },
|
|
createdAt: now,
|
|
});
|
|
return { ok: true as const, slug: normalized.slug, status: "draft" as const };
|
|
}
|
|
|
|
async function updatePromotionForActor(
|
|
ctx: MutationCtx,
|
|
actor: Doc<"users">,
|
|
slug: string,
|
|
input: PromotionInput,
|
|
) {
|
|
assertAdmin(actor);
|
|
const existing = await getPromotionBySlug(ctx, normalizePromotionSlug(slug));
|
|
if (!existing) throw new ConvexError("Promotion not found");
|
|
|
|
const normalized = normalizePromotionInput(input);
|
|
if (normalized.slug !== existing.slug) {
|
|
// Slugs are referenced by external links and CLI claim provenance once a
|
|
// promotion has been activated; only drafts may be renamed.
|
|
if (existing.status !== "draft") {
|
|
throw new ConvexError("Slug can only be changed while the promotion is a draft");
|
|
}
|
|
const collision = await getPromotionBySlug(ctx, normalized.slug);
|
|
if (collision) throw new ConvexError(`Promotion already exists: ${normalized.slug}`);
|
|
}
|
|
|
|
const now = Date.now();
|
|
const hasLaunched =
|
|
existing.launchedAt !== undefined || (existing.status === "active" && existing.startsAt <= now);
|
|
if (existing.status === "active" && !hasLaunched && normalized.endsAt < now) {
|
|
throw new ConvexError("Unlaunched promotions cannot be moved to an expired window");
|
|
}
|
|
const launchedAt = resolvePromotionLaunchedAt(
|
|
existing,
|
|
existing.status,
|
|
normalized.startsAt,
|
|
now,
|
|
);
|
|
await ctx.db.replace(existing._id, {
|
|
...normalized,
|
|
status: existing.status,
|
|
...(launchedAt !== undefined ? { launchedAt } : {}),
|
|
createdByUserId: existing.createdByUserId,
|
|
createdAt: existing.createdAt,
|
|
updatedByUserId: actor._id,
|
|
updatedAt: now,
|
|
});
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: actor._id,
|
|
action: "promotion.update",
|
|
targetType: "promotion",
|
|
targetId: normalized.slug,
|
|
metadata: { promotionId: existing._id, previousSlug: existing.slug },
|
|
createdAt: now,
|
|
});
|
|
await schedulePromotionsFeedRepublication(ctx, {
|
|
status: existing.status,
|
|
startsAt: normalized.startsAt,
|
|
endsAt: normalized.endsAt,
|
|
});
|
|
return { ok: true as const, slug: normalized.slug, status: existing.status };
|
|
}
|
|
|
|
async function setPromotionStatusForActor(
|
|
ctx: MutationCtx,
|
|
actor: Doc<"users">,
|
|
slug: string,
|
|
status: PromotionStatus,
|
|
) {
|
|
assertAdmin(actor);
|
|
const existing = await getPromotionBySlug(ctx, normalizePromotionSlug(slug));
|
|
if (!existing) throw new ConvexError("Promotion not found");
|
|
if (status === "draft" && existing.status !== "draft") {
|
|
throw new ConvexError("Published promotions cannot return to draft");
|
|
}
|
|
if (existing.status === "draft" && status === "ended") {
|
|
throw new ConvexError("Draft promotions must be activated before they can end");
|
|
}
|
|
if (existing.status === "ended" && status === "active") {
|
|
throw new ConvexError("Ended promotions cannot be reactivated");
|
|
}
|
|
if (existing.status === status) {
|
|
return { ok: true as const, slug: existing.slug, status };
|
|
}
|
|
const now = Date.now();
|
|
if (status === "active") {
|
|
if (existing.endsAt < now) {
|
|
throw new ConvexError("Expired promotions cannot be activated");
|
|
}
|
|
const activePromotions = await ctx.db
|
|
.query("promotions")
|
|
.withIndex("by_status_endsAt", (q) => q.eq("status", "active"))
|
|
.take(ACTIVE_SET_LIMIT);
|
|
if (activePromotions.length >= ACTIVE_SET_LIMIT) {
|
|
throw new ConvexError(
|
|
`At most ${ACTIVE_SET_LIMIT} promotions can be active; end an existing promotion first`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const launchedAt = resolvePromotionLaunchedAt(existing, status, existing.startsAt, now);
|
|
await ctx.db.patch(existing._id, {
|
|
status,
|
|
...(launchedAt !== undefined ? { launchedAt } : {}),
|
|
updatedByUserId: actor._id,
|
|
updatedAt: now,
|
|
});
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: actor._id,
|
|
action: "promotion.set_status",
|
|
targetType: "promotion",
|
|
targetId: existing.slug,
|
|
metadata: { promotionId: existing._id, from: existing.status, to: status },
|
|
createdAt: now,
|
|
});
|
|
await schedulePromotionsFeedRepublication(ctx, {
|
|
status,
|
|
startsAt: existing.startsAt,
|
|
endsAt: existing.endsAt,
|
|
});
|
|
return { ok: true as const, slug: existing.slug, status };
|
|
}
|
|
|
|
// Dashboard entry points (Convex auth session).
|
|
export const create = mutation({
|
|
args: promotionInputArgs,
|
|
handler: async (ctx, args) => {
|
|
const { user } = await requireUser(ctx);
|
|
return createPromotionForActor(ctx, user, args);
|
|
},
|
|
});
|
|
|
|
export const update = mutation({
|
|
args: { targetSlug: v.string(), ...promotionInputArgs },
|
|
handler: async (ctx, args) => {
|
|
const { user } = await requireUser(ctx);
|
|
const { targetSlug, ...input } = args;
|
|
return updatePromotionForActor(ctx, user, targetSlug, input);
|
|
},
|
|
});
|
|
|
|
export const setStatus = mutation({
|
|
args: { slug: v.string(), status: promotionStatusArgValidator },
|
|
handler: async (ctx, args) => {
|
|
const { user } = await requireUser(ctx);
|
|
return setPromotionStatusForActor(ctx, user, args.slug, args.status);
|
|
},
|
|
});
|
|
|
|
// HTTP API entry points (API-token auth resolved in the handler).
|
|
export const createInternal = internalMutation({
|
|
args: { actorUserId: v.id("users"), input: v.object(promotionInputArgs) },
|
|
handler: async (ctx, args) => {
|
|
const actor = await requireActorFromId(ctx, args.actorUserId);
|
|
return createPromotionForActor(ctx, actor, args.input);
|
|
},
|
|
});
|
|
|
|
export const updateInternal = internalMutation({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
targetSlug: v.string(),
|
|
input: v.object(promotionInputArgs),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const actor = await requireActorFromId(ctx, args.actorUserId);
|
|
return updatePromotionForActor(ctx, actor, args.targetSlug, args.input);
|
|
},
|
|
});
|
|
|
|
export const setStatusInternal = internalMutation({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
slug: v.string(),
|
|
status: promotionStatusArgValidator,
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const actor = await requireActorFromId(ctx, args.actorUserId);
|
|
return setPromotionStatusForActor(ctx, actor, args.slug, args.status);
|
|
},
|
|
});
|
|
|
|
// Activation enforces ACTIVE_SET_LIMIT, so this read remains bounded while
|
|
// still considering every curated active or scheduled promotion.
|
|
async function collectActivePromotions(ctx: QueryCtx, now: number) {
|
|
const promotions: Array<ReturnType<typeof toPublicPromotion>> = [];
|
|
let nextStartsAt: number | null = null;
|
|
const active = await ctx.db
|
|
.query("promotions")
|
|
.withIndex("by_status_endsAt", (q) => q.eq("status", "active").gte("endsAt", now))
|
|
.take(ACTIVE_SET_LIMIT);
|
|
for (const promotion of active) {
|
|
if (promotion.startsAt > now) {
|
|
nextStartsAt =
|
|
nextStartsAt === null ? promotion.startsAt : Math.min(nextStartsAt, promotion.startsAt);
|
|
continue;
|
|
}
|
|
if (!isPromotionActive(promotion, now)) continue;
|
|
promotions.push(toPublicPromotion(promotion, now));
|
|
}
|
|
return { promotions, nextStartsAt };
|
|
}
|
|
|
|
export const listActiveInternal = internalQuery({
|
|
args: { now: v.number() },
|
|
handler: async (ctx, args) => collectActivePromotions(ctx, args.now),
|
|
});
|
|
|
|
export const getBySlugPublicInternal = internalQuery({
|
|
args: { slug: v.string(), now: v.number() },
|
|
handler: async (ctx, args) => {
|
|
const promotion = await getPromotionBySlug(ctx, normalizePromotionSlug(args.slug));
|
|
// Drafts and every pre-launch state stay hidden so unreleased launch
|
|
// details cannot be read by guessing the slug. Ended promotions remain
|
|
// visible after launch so stale links can render an "ended" state.
|
|
if (!promotion || promotion.status === "draft") return null;
|
|
if (promotion.status === "ended") {
|
|
return promotion.launchedAt === undefined ? null : toPublicPromotion(promotion, args.now);
|
|
}
|
|
if (args.now < promotion.startsAt) return null;
|
|
return toPublicPromotion(promotion, args.now);
|
|
},
|
|
});
|
|
|
|
export const listAllInternal = internalQuery({
|
|
args: { paginationOpts: paginationOptsValidator },
|
|
handler: async (ctx, args) => {
|
|
const result = await ctx.db.query("promotions").order("desc").paginate(args.paginationOpts);
|
|
const now = Date.now();
|
|
return {
|
|
...result,
|
|
page: result.page.map((promotion) => toPublicPromotion(promotion, now)),
|
|
};
|
|
},
|
|
});
|
|
|
|
export const listForStaff = query({
|
|
args: { paginationOpts: paginationOptsValidator },
|
|
handler: async (ctx, args) => {
|
|
const { user } = await requireUser(ctx);
|
|
// Admin-only like every other promotions surface: drafts carry unreleased
|
|
// launch windows and sponsor details.
|
|
assertAdmin(user);
|
|
return await ctx.db
|
|
.query("promotions")
|
|
.order("desc")
|
|
.paginate({
|
|
...args.paginationOpts,
|
|
numItems: Math.min(args.paginationOpts.numItems, STAFF_LIST_PAGE_SIZE),
|
|
});
|
|
},
|
|
});
|