Commit Graph

1176 Commits

Author SHA1 Message Date
Manuel Emilio Urena 6b58d1fa89 fix(models): cascade nsfw flip to version rollups (#2288)
When Model.nsfw was true, every ModelVersion.nsfwLevel was stamped to
nsfwBrowsingLevelsFlag (60 = R|X|XXX|Blocked). The old Model trigger
only enqueued a Model-level UpdateNsfwLevel job on nsfw flip, so after
a true->false flip the versions stayed at 60. updateModelNsfwLevels
then bit_or'd the stale 60s back to 60 and the WHERE clause found no
diff — model frozen, hidden from .com search.

Trigger now enqueues a ModelVersion UpdateNsfwLevel job for every
Published version under the model whenever Model.nsfw changes. The
update-nsfw-levels cron processes versions before models in the same
tick (see updateNsfwLevels batch order), so the rollup reads fresh
version data. Also swapped the prior `!=` comparison to
IS DISTINCT FROM for NULL-safety.

Backfill: a hidden debug endpoint at
src/pages/api/testing/backfill-stale-nsfw-rollups.ts (guarded by
WEBHOOK_TOKEN) enqueues UpdateNsfwLevel jobs for the existing stuck
cohort (~3,198 models / ~5,264 versions). Supports count, enqueue
(with dryRun / limit / modelId), and verify actions.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:47:45 -04:00
Zachary Lowden a5e252c033 fix(model-file-scan): correct HiDream-O1 baseModel to match ecosystem key (#2289)
Training-completion code wrote `ModelVersion.baseModel = 'HiDream O1'`
(with space) since the ecosystem shipped on 2026-05-13. stringifyAIR
hands the string to getRootEcosystem(), which throws (canonical key is
`HiDream-O1` with hyphen). The try/catch silently keeps the raw string,
lowercases it, and emits `urn:air:hidream o1:lora:civitai:...` to the
orchestrator. AIR segments are colon-separated and don't allow spaces;
the orchestrator's URN parser falls back to `unknown:unknown:...` and
the scan workflow fails with HTTP 400 ("Resource ... does not exist or
is not valid.").

100% of HiDream-O1 scan submissions have been failing since 2026-05-13
under the orchestrator-only scan path (Phase 3 removed the legacy
HTTP fallback on 2026-05-11). Backfill the persisted rows so the
scan-files-fallback job (every 5 min) picks them up on its next tick
and resubmits with the corrected baseModel.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:22:09 -05:00
Justin Maier b03829a8a0 feat(oauth): require Origin allowlist for public clients (#2285)
* feat(oauth): require Origin allowlist for public clients

Public OAuth clients (isConfidential=false) currently rely on PKCE alone
for token-exchange identity. This adds an `allowedOrigins` field to
OauthClient and enforces it on /api/auth/oauth/token and /revoke for
public clients. CORS becomes per-origin instead of `*` when the request
matches a registered origin.

Confidential clients are unchanged — `client_secret` remains the auth
boundary so they keep the existing wildcard CORS.

The migration backfills existing OauthClient rows with allowedOrigins
derived from the origin part of their redirectUris, so the rollout is
non-breaking for any currently-registered application.

Closes the residual code-interception window for browser-only PWA
clients ahead of the civitai-app-starters static-PWA M7 work.

* fix(oauth): always GET-redirect from consent approval

Next.js's res.redirect() defaults to a 307 status, which preserves the
HTTP method on the follow-up request. The consent page POSTs to
/api/auth/oauth/authorize on Approve, so the 307 back to the registered
redirect_uri caused the browser to re-POST the code/state to the third-
party callback. Standard OAuth clients only define GET on their
callback URL per RFC 6749 §4.1.2 and respond 405 to that POST — which
broke first-time consent in e2e against next-app while second-time
consent (skipped UI, original GET preserved) still worked.

Switch all three redirects in the authorize handler to an explicit 303
so the browser always downgrades to GET, matching the spec regardless
of how the request arrived.

* refactor(oauth): single-lookup origin enforcement

Move per-client origin enforcement into oauthModel.getClient so the
token endpoint does one DB lookup instead of two. The model stashes
the resolved client on the OAuth library's Request so the handler can
drive CORS off it without re-querying. Bypass for the /authorize flow
gated on body.grant_type (only set on /token + /revoke); native PKCE
clients with no Origin header are allowed through (lets one OAuth
client back both a browser SPA and a mobile app with a single
consent).

Other changes from PR review:
- Migration: NOT NULL DEFAULT, normalized backfill (lowercase host,
  scheme-aware default-port stripping, [^/?#]+ to stop at
  query/fragment, ~* case-insensitive scheme match).
- deriveAllowedOriginsFromRedirectUris filters to http/https.
- Fail-closed fallback dbRead lookup if the library success path
  doesn't stash a client.
- Rate-limit /token by IP instead of clientId (random-id rotation
  was bypassing the bucket and driving model-layer lookups).
- Collapse /revoke into one findUnique and rate-limit before the
  origin check.
- Drop Access-Control-Allow-Credentials from public-client CORS
  (Bearer tokens, no cookies).

Adds OriginNotAllowedError sentinel + dedicated tests for
oauthModel.getClient covering the new gating, plus a fail-closed
fallback test for the /token handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(oauth): defense-in-depth origin check + diagnostic logging

- /token success path: verify request Origin is in
  attached.allowedOrigins before echoing per-origin CORS. Redundant
  with oauthModel.getClient's check on the normal path but closes the
  fail-closed fallback path so an unverified Origin can never be
  echoed (addresses CodeQL CORS-misconfiguration alert + Copilot
  defense-in-depth comment).
- /revoke: same allowlist-includes guard documented inline (logic was
  already there, comment now explicit).
- /token catch: console.error the swallowed error so a 500 surfaces
  in dev/prod logs with a stack trace instead of an opaque response.
- /authorize: separate res.redirect(...) calls from the return
  statement. res.redirect returns the res object, and returning it
  from a Next.js Pages API handler triggers the "API handler should
  not return a value, received object" warning in dev.
- oauthModel.getClient: correct misleading comment that claimed
  /revoke uses grant_type — it uses token/token_type_hint per RFC
  7009 and doesn't go through the OAuth model at all.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:04:58 -06:00
Manuel Emilio Urena 7b59f2c6a6 feat(reports): add Spam report reason (#2283)
* feat(reports): add Spam report reason

Adds Spam as a first-class ReportReason alongside the existing
ViolationType.Spam (which remains for the mod-side unpublish flow).
New reports land in the mod queue at status Pending and appear in
the default reason filter. ClickUp 868jne7my.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(reports): inherit base details default on Spam schema

Drop the details override on reportSpamSchema so the discriminated
union picks up baseSchema's `details: baseDetailSchema.default({})`,
matching the CSAM precedent. Without this, callers had to send an
explicit empty `details` object for Spam reports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:21:57 -04:00
Briant Diehl 07725d8d78 updated xguard labels 2026-05-15 17:27:37 -06:00
Briant Diehl 231e579fa5 Merge remote-tracking branch 'origin/main' into feature/prompt-wildcards 2026-05-14 13:26:20 -06:00
Briant Diehl da1f0d4b23 wildcards: set-level nsfwLevel rollup + audit-driven classification
- WildcardSet.nsfwLevel column (folded into the original add_wildcard_sets
  migration). Bitwise OR of every non-Dirty category's nsfwLevel; maintained
  by recomputeWildcardSetAuditStatus so visibility checks ("does this set
  have content fitting .com SFW vs .red NSFW?") are a single-table bitmask
  test, no category sub-query.

- Audit pipeline classifies severity via XGuard. Two label sets per workflow:
  hard-fail labels (csam, urine, diaper, scat, menstruation, bestiality) ->
  Dirty; level label (nsfw for v1) -> NsfwLevel.R, else NsfwLevel.PG. v1
  restricts to the binary nsfw evaluator because pg/pg13/r/x/xxx aren't
  well-tuned for text yet -- the schema stays bitwise so re-introducing
  finer levels later is code-only.

- Callback recomputes Dirty from per-label triggered flags rather than
  trusting output.blocked, which would otherwise flip on triggered level
  labels (ordinary NSFW content would be marked Dirty).

- getResourceData stamps wildcardSetId + overrides canGenerate for
  Wildcards-type ModelVersions, using the set-level rollup for visibility.
  Single-table query, no JOIN.

- GenerationResource.wildcardSetId optional field -- downstream callers
  (form hydration, model detail page "Generate" handoff) read it to route
  the id into snippets.wildcardSetIds rather than appending to resources[].

- Docs (prompt-snippets-v1.md, prompt-snippets-schema.md) updated to
  describe the rollup, audit label sets, and "wildcards aren't generation
  resources" routing model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:14:28 -06:00
Briant Diehl 51d29d8e8c check in 2026-05-14 11:16:22 -06:00
Briant Diehl 44275e48d1 Switches the ClickHouse audit log to AggregatingMergeTree so duplicate
scans collapse into one decision-grain row with occurrences summed.
Mod review tables collapse to a single `ScannerLabelReview` keyed by
the same three columns — verdict once, covers all future identical
scans.

- policyHash -> version end-to-end (column + workflow metadata)
- writer computes contentHash from scan input; standalone
  recordXGuardScan dropped in favor of the workflow helper
- queue + detail queries use GROUP BY with explicit aggregates in
  HAVING/ORDER BY + SETTINGS prefer_column_name_to_alias = 1 for
  partition-pruning WHERE on lastSeenAt
- /moderator/scanner-audit rebuilt around the new row unit with
  occurrence count + per-label verdict buttons
- new docs/features/scanner-pending-migrations.md lists the
  Postgres migration, ClickHouse SQL, and Prisma regen step

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:44:08 -06:00
Briant Diehl ef95bf8404 feat(image-scan): add AiRecognition + AnimeRecognition tag sources
Adds dedicated TagSource enum values for the new mediaRating
classifier signals so downstream operational tagging (TagsOnImageDetails)
can carry clean provenance for ai/anime tags rather than reusing
Computed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:42:58 -06:00
Briant Diehl 9b27143ff1 feat(scanner-review): moderator review UI for scanner results
The consumer side of the scanner audit log. Mods FP-review triggered
scans and FN-review near-miss scans; verdicts feed prompt tuning.

- ScannerScanReview (scan-level completion marker) + ScannerReview
  (per-label TP/FP/TN/FN/Unsure) + ReviewVerdict enum, all joined to
  ClickHouse by workflowId (string, unenforced FK).
- /moderator/scanner-audit page with Triggered / Near-miss tabs,
  scanner+label+policyVersion filters, paginated table, drawer detail
  with per-label verdict buttons + matched-terms display + Mark scan
  reviewed submit. CSV export pulls up to 50k rows for offline analysis.
- scannerReview tRPC router: list / detail / upsertVerdict /
  deleteVerdict / submitReview / exportRows, all moderator-gated.

Two-table design separates "this scan has been reviewed" from "this
label had this verdict" so coverage tracking and disagreement spotting
stay clean even across multiple mods on the same scan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:42:42 -06:00
Briant Diehl 2f42cc972a Merge remote-tracking branch 'origin/main' into feature/prompt-wildcards 2026-05-08 15:52:12 -06:00
Justin Maier 9dcc9e6cd4 feat(tips): add user/version flags to disable creator tips
Adds bitmask `flags Int` columns on User and ModelVersion. First bit
(`DisableTips`) opts the user/version out of creator tips. The mini
endpoint now returns a `creatorTips` boolean = neither flag set.

Use cases: Civitai official account (user-level), licensed models
(version-level).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 12:05:29 -06:00
Justin Maier 055e54f6e6 feat(oauth): scoped tokens, OAuth 2.0 server, per-subject buzz limits
Squashed merge of feature/scoped-tokens onto latest main.

OAuth 2.0 server (authorization code + PKCE, refresh, revoke, device
flow, OIDC discovery), bitwise TokenScope enum (25 flags) with a
fail-safe enforceTokenScope middleware (un-annotated procedures
default to requiring Full), 83 routers annotated, 15 buzz-spending
procedures gated with blockApiKeys: true.

Per-subject buzz limits: opaque (type, id) subject pair,
BuzzBudget[] shape supporting absolute/sliding/rollover variants
with optional currency filters, stored on ApiKey.buzzLimit
(User-type keys) or OauthConsent.buzzLimit (OAuth grants — stable
across access-token rotations). Civitai stores limits + busts
cache + cleans up subjects via /v1/manager/users/:userId/{auth,
limits/auth}/:type/:id; orchestrator owns enforcement and
rolling-window math.

Account UI: card-based ApiKeys, OAuthApps, ConnectedApps surfaces
with inline spend bars + a shared EditBuzzLimitModal. OAuth consent
screen collects an optional buzz limit when AIServicesWrite is
requested. OAuth Apps + Connected Apps gated behind the
`oauth-apps` Flipt flag (mod-only). Audit via the existing
ClickHouse `actions` table (BuzzLimit_Set ActionType).

DB: one new migration 20260507165710_add_buzz_limit_to_oauth_consent
adds OauthConsent.buzzLimit JSONB. Legacy KeyScope[] column drop is
deferred to a follow-up PR after this is stable in prod.

Demo client: civitai/civitai-oauth-demo (separate repo).

Conflict resolution during the rebase onto main: kept main's newer
multi-image candidate handling in comics.router.ts (it was a
substantive content divergence, not a metadata conflict; the
blockApiKeys: true annotation on purchaseChapterAccess was already
preserved through the non-conflicting merge regions). Prisma types
regenerated post-merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:07:41 -06:00
Briant Diehl af3f086b71 added wildcard category auditing 2026-05-07 16:05:33 -06:00
Briant Diehl 95d84c2526 WildcardSet services 2026-05-07 15:22:17 -06:00
Briant Diehl 8f5d58d139 job to scan wildcard models to create new wildcard sets 2026-05-07 14:14:30 -06:00
Luis Rojas c61819f6cb Merge branch 'main' of github.com:civitai/civitai 2026-05-07 11:36:23 -04:00
Luis Rojas ca915ed55b Gate mature comic-panel generations behind explicit unlock
* Stop persisting orchestrator blurred previews on the panel — mature
    outputs (single-image OR per-candidate slots) now sit in a new
    ComicPanelStatus.RequireUnlock state with  markers in
    metadata; the panel's imageUrl/imageId are never written for blocked
    results, closing the leak where the CDN link could be discovered
    through any surface that read panel.imageUrl.
  * Derive blockedReason via formatGenerationResponse2 + WorkflowData
    rather than reading raw orchestrator output, so domain/nsfwEnabled/
    allowMatureContent gating produces the same  /
     reasons the rest of the app relies on.
  * unlockPanelGeneration now lifts the workflow restriction at the
    orchestrator AND inline-downloads the now-clean output(s), updating
    metadata in one round-trip — no waiting for a poll tick.
  * CTA is strictly domain-based: green redirects to civitai.red
    (server-side mutation also rejects on green); red shows the in-place
    yellow-Buzz unlock. The per-tile reveal in the candidate picker is
    also gated this way so the Show button isn't a green-side backdoor.
  * Capture per-candidate orchestrator nsfwLevel so the picker blurs
    clean-but-mature outputs by default, with selection/zoom disabled
    until the user explicitly reveals.
2026-05-07 11:35:59 -04:00
Justin Maier 1b57f46003 feat(licensing-fees): per-image licensing fees on ModelVersion (#2240)
* feat(model-version): add licensing fee columns

* feat(model-version): expose fee on mini endpoint

* feat(model-version): licensing rate editor UI behind Flipt flag

* feat(buzz): daily license fee payout job

* feat(buzz-dashboard): license earnings chart segment

* chore: prettier format

* fix(model-version): include licensing fee fields in edit selector

* refactor(buzz): fold license fee payout into creator-comp job

* refactor(buzz): inline source filter in compensation query

* fix(model-version): keep editor visible when existing fee set
2026-05-06 14:48:37 -06:00
Briant Diehl 2978847888 initial migration 2026-05-05 15:48:12 -06:00
Manuel Emilio Urena 25696fb4f4 fix(models): clamp lastVersionAt to non-future Published versions (#2231)
* fix(models): clamp lastVersionAt to non-future Published versions

updateModelLastVersionAt picked the latest "status=Published, publishedAt
not null" version and wrote that publishedAt straight into Model.lastVersionAt
with no upper bound. If a Published row ever held a future publishedAt
(e.g. a Scheduled→Published edit that left publishedAt untouched, then an
auto-unpublish demoted it back to Draft without the corrector re-running),
the future date stuck on Model.lastVersionAt and the prod-side
trg_sync_model_to_metric trigger mirrored it into ModelMetric.lastVersionAt,
pinning the model to the top of the Newest feed (sorted by mm."lastVersionAt"
DESC) with the "Updated" badge active until the future date arrived.

Add `lte: new Date()` to the lookup so future publishedAt values can never
seed lastVersionAt. The backfill migration repairs any currently-poisoned
rows; trg_sync_model_to_metric handles the propagation into ModelMetric.

ClickUp: 868jfgvnm

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(models): tighten sync_model_to_metric trigger

Two changes to the prod-side Model→ModelMetric sync trigger:

1. Refuse to propagate a future Model.lastVersionAt into ModelMetric.
   Defense in depth around updateModelLastVersionAt's lte:NOW guard —
   even if some other path writes a future Model.lastVersionAt
   (manual SQL fix, admin script, future code regression), the feed
   table mm.lastVersionAt stays clean and the model can't pin to the
   top of the Newest feed.

2. Split AFTER INSERT OR UPDATE into two triggers so the UPDATE half
   carries a WHEN (...) clause and only fires when a synced column
   actually changed. Stops ModelMetric write churn from every
   unrelated Model UPDATE; matches the trg_sync_model_to_base_model_metric
   pattern.

Idempotent: function uses CREATE OR REPLACE, triggers are dropped
with IF EXISTS before being recreated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:15:51 -04:00
Manuel Emilio Urena 4b000523bc feat(model-version): add ExternalGeneration for fileless API-backed models (#2217)
* feat(model-version): add ExternalGeneration usage control for fileless API-backed models

Lets moderators publish file-less model versions (e.g. NanoBanana, Seedream,
ChatGPT-Images) that are routed via dedicated external-engine UIs. The new
ModelUsageControl.ExternalGeneration value is mod-only to set, skips the
wizard's file-upload step, is excluded from the reset-to-draft cron, and
is auto-covered by the GenerationCoverage view so canGenerate and model-page
badges work for all users without a manual EcosystemCheckpoints insert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(generation-coverage): guard ExternalGeneration branch with NOT m.poi

Defense-in-depth from Copilot review: prevents a mod from silently making a
PoI-flagged model generatable by flipping usageControl=ExternalGeneration.
Mirrors the safeguard already in the catch-all branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(model-version-wizard): respect usageControl in edit-mode submit

The previous editing branch returned early via goNext() before checking
usageControl, so saving an existing version (or re-submitting after the
wizard re-mounted in edit mode) always advanced to step 2 (Files) even
when usageControl was ExternalGeneration. Unify the two paths to always
navigate by URL with shallow routing when editing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(model-wizard): skip files step for ExternalGeneration in full-model wizard

Mirror the ExternalGeneration carve-out from ModelVersionWizard:
- Step 2 (version) submit jumps to step 4 (post) instead of step 3 (files)
  when usageControl is ExternalGeneration.
- The auto-redirect useEffect respects the same skip when no version has
  files yet but the version is mod-published as ExternalGeneration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(wizard): hide Files step from stepper for ExternalGeneration versions

For mod-published file-less versions, the Files step is irrelevant — instead of
just auto-skipping past it, omit it from the rendered Stepper entirely so the UI
matches the actual flow (Edit version → Create post for the version wizard;
Model info → Version info → Create post for the new-model wizard).

URL step semantics stay stable (Files is still URL step 2 / 3), so direct links
keep working. The Stepper's active index and onStepClick handler translate
between URL step and rendered child position when the Files child is omitted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:47:10 -04:00
Justin Maier f741b86db4 feat(home-blocks): add FeaturedCollections type + mod curation
New HomeBlockType that expands one system HomeBlock (userId=-1) into
N randomly-picked collection sections on the homepage, each with a
curator-attributed header. Pool of collection IDs lives in metadata
and is mod-curated.

Mod surface:
- "Feature on homepage" toggle in collection context menu
- /moderator/home-blocks/featured-collections admin page
- "Featured Collections" entry in the moderator nav menu

Safety rails:
- Hourly refresh-featured-collections-eligibility cron writes a
  consolidated Redis state blob with per-pool recent-item counts
  and name-drift detection (threshold: >=3 accepted items/5d AND
  name matches approved snapshot)
- Hydration reads eligibility from Redis, falls back to full pool
  on Redis miss, returns null when the job ran and nothing qualifies
- Collection name snapshot captured on add; hydration drops the
  collection until a mod re-approves via the admin UI
- sfwBrowsingLevelsFlag applied server-side so the block is always
  PG/PG-13 regardless of user preference
- Picks whose post-filter items are empty are dropped so a pool
  entry of all-R/X items never renders an empty section
- Ghost entries (deleted/missing collections) surface in admin UI
  with a remove action
- Clone user blocks stay untouched on pool mutation; runtime reads
  pool state from the source block via sourceId
- Zod + runtime clamps on limit/rows/renderCount/staleDays

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 20:22:41 -06:00
Justin Maier aa1922b4ba feat(referral): spend-triggered referral program v2 (#2178)
* feat(referral): add spend-triggered referral program v2

Referrers earn Tokens (1/2/3 per Bronze/Silver/Gold membership month paid,
capped at 3 months per referee) + 10% Blue Buzz kickback on referee yellow
Buzz purchases. Referees get 25% of tier monthlyBuzz as Blue Buzz on first
paid membership. 7-day settlement window with chargeback revoke. Tokens
redeemable in shop for perk-only CustomerSubscription grants (no buzz stipend,
no tier badge). Gated by Flipt flag referral-program-v2.

- Prisma: ReferralReward, ReferralMilestone, ReferralRedemption models
- UserReferral.firstPaidAt + paidMonthCount for cap tracking
- referral.service.ts handles earning, settlement, clawback, milestones
- Stripe webhook hooks manageInvoicePaid + completeStripeBuzzTransaction
- charge.refunded + charge.dispute.created webhooks for clawback
- Cookie TTL 5d -> 30d, single auto code per user
- referral:* signals (pending, settled, milestone, tier-granted, etc.)
- /user/referrals dashboard with shop modal + activity feed
- Checkout banner with manual code entry + bonus preview
- Terms at src/static-content/referrals/terms.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(referral): address review feedback from code + gap analysis

- Add @@unique([kind, sourceEventId]) + @@index([status, settledAt]) on
  ReferralReward. Replaces fetch-then-insert dedupe with unique-violation
  catch; closes idempotency race on webhook retries.
- Make sourceEventId NOT NULL (always set in practice)
- Fix settleRewardRow: move buzz grant before status flip and revert claim on
  buzz transaction failure. Prior version left Settled status after a
  post-commit rollback attempt.
- revokeForChargeback now handles Settled rewards via negative
  (ChargeBack) createBuzzTransaction, not just Pending.
- Chargeback webhook looks up the linked invoice for the PI so membership
  rewards (sourceEventId = invoice.id) are also revoked.
- Enforce minReferrerAccountAgeDays in resolveReferrerForReferee.
- getReferrerBalance: single groupBy query replaces 4 aggregates.
- redeemTokens: FOR UPDATE lock on settled token rows; grant actual
  CustomerSubscription via findReferralProductForTier. Requires admin
  setup of Civitai-provider products flagged referralGrantable:true.
- awardMilestones: deterministic sourceEventId (userId:threshold) and P2002
  catch prevent double-award under concurrent settlement.
- Referee bonus consolidated into the membership payment transaction.
- ReferralCheckoutBanner now self-fetches per-tier monthly buzz via
  trpc.referral.getTierBonuses and displays the computed bonus.
- trackCheckoutView moved to publicProcedure (anonymous guests can hit it).
- logFraudEvent moved after dedupe checks so only real events log.
- Constants: REWARD_DESCRIPTIONS table, promoted REFERRAL_SYSTEM_ACCOUNT_ID.

Known gaps deferred to follow-ups: Paddle buzz kickback hook,
referral:click emitter + dashboard stat, share buttons (Twitter/Reddit/
Discord), expiring-soon UI card, redemption-vs-paid-tier UI check, Top
Affiliate cosmetic on 1M milestone. See review compile for full list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(referral): address Justin's second-pass feedback

Key changes following review:

- Schema source of truth: moved all referral additions from the generated
  schema.prisma to schema.full.prisma so db:generate regenerates correctly.
- Referral grants use CustomerSubscription.buzzType='referral' (new distinct
  value) so they stack with paid yellow/green/blue subs via session-user's
  highest-tier-across-all aggregation. @@unique([userId, buzzType]) no longer
  blocks a user from holding paid + referral at the same time. On re-redeem,
  existing referral sub is extended (same/lower tier) or upgraded
  (higher tier).
- Attribution logging moved from Axiom to Postgres. New ReferralAttribution
  table links each attribution event to UserReferralCode + payment metadata
  (Stripe PI id, invoice id, charge id, card fingerprint, IP). Indexes
  support "all events for code/card/IP" queries for mod review.
- Stripe invoice + PI webhooks forward payment identifiers + card
  fingerprint into the attribution record.
- Paddle webhook gets a clarifying deprecation comment; no new referral
  paths touch Paddle.
- Notifications (persistent + email) added alongside signals for:
  - referral-reward-settled
  - referral-milestone-hit
  - referral-token-expiring (daily cron, deduped per-user-per-expiry-date)
  - referral-welcome-bonus (referee gets in-app + email thank-you)
- /user/referrals dashboard gains Share-on-X, Share-on-Reddit, and
  Share-on-Discord buttons in the hero.
- 1M-milestone Top Affiliate badge grants the most-recent cosmetic as a
  placeholder until a bespoke one is authored.
- Design doc (referral-program.md) extended with overlap stacking approach,
  attribution + fraud detection plan, and notification matrix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(referral): tier queue redemptions, no exploit path

Mirrors the prepaid-membership pattern: each redemption becomes a tier-time
chunk stored on CustomerSubscription.metadata.referralQueue. Chunks sort by
tier DESC so Gold always activates before Bronze. Same-tier chunks collapse
into a single entry so the metadata stays small.

On redemption: pool the currently-active chunk's remaining days + existing
queue + new chunk, sort/collapse, promote the top chunk to active, persist
the rest as queue. Cheap-Bronze-stacked-to-Gold exploit is impossible because
chunks never change tier — a Bronze chunk grants Bronze for its own days,
then the next chunk runs on its own terms.

New hourly cron advance-referral-subs: for referral subs past
currentPeriodEnd with a non-empty queue, promote the next chunk; otherwise
cancel the sub.

Static modal copy updated to reflect the new behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(referral): unit tests for queue, earning, chargeback, milestones

24 vitest cases covering:

- collapseTierQueue (pure): sort order, same-tier collapse, zero-duration
  drops, and the "cheap Bronze stacked with one Gold" exploit is impossible
- recordMembershipPaymentReward: no-bound-referrer skip, 3-month cap,
  first-payment referee bonus creation, subsequent-month no-bonus path,
  P2002 idempotency, min-referrer-age rejection
- recordBuzzPurchaseKickback: skip when referee never paid, 10% rate
  calculation
- revokeForChargeback: pending (no buzz clawback) vs settled (negative buzz
  txn with referral-clawback:<id> externalTransactionId)
- awardMilestones: no-op on zero lifetime, awards only qualifying
  thresholds, swallows P2002 on duplicate milestone
- advanceReferralSubscriptions: empty-queue cancels, non-empty promotes
  highest tier, missing referralGrantable product skips (no partial
  progress)

Collapse exports collapseTierQueue for direct testing. DB clients mocked
with deep shape for each model used.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(referral): timeline progress bar on dashboard

Adds a segmented progress bar to /user/referrals showing the user's queued
tier-time chunks. Mirrors the prepaid membership timeline pattern: each
chunk is colored by tier (gold/silver/bronze), annotated with tooltips
showing its date range and days-in-tier, and the active chunk is visually
highlighted.

getDashboard now returns referralGrant (activeTier, period bounds, queue).
New ReferralTimelineProgress component renders the segmented bar only when
referralGrant is present. Nothing shown if user has no active referral sub.

Answers Justin's visibility concern — users can now see "Gold for 14 days,
then Bronze for 42" rather than guessing at metadata.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(referral): add review walkthrough checklist

Section 0 lists pre-review setup: migrations, Civitai referral product
inserts, Flipt flag, terms doc placeholders. Sections 1-6 walk the design
docs, code-by-area, manual smoke tests, known gaps, design questions, and
sign-off checklist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(referral): lock effective date + governing law

Effective Date: April 21, 2026.
Governing Law: Delaware (matches main TOS Section 19.2).

Flipt flag referral-program-v2 added upstream in civitai/flipt-state and
enabled by default (commit f09d584 there).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(referral): generic ReferralRedemption + flatten migrations

Collapses three migrations into one (safe — none applied yet):
- 20260420153258_add_referral_program_v2 (original tables)
- 20260420170000_add_referral_attribution
- 20260421120000_referral_redemption_generic (redemption refactor)

New consolidated migration 20260421120000_add_referral_program_v2 captures
the final target schema in one file.

ReferralRedemption drops tier/durationDays/subscriptionId in favor of:
- rewardType: ReferralRedemptionType enum ('MembershipPerks' only today)
- metadata: JSONB with shape { tier, durationDays, subscriptionId }

Leaves room for future redemption types (BuzzGrant, cosmetic, etc.) without
another migration. Service + dashboard UI updated to read metadata.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(prepaid): exclude referral buzzType from prepaid cron

Both the daily tier-transition sweep and the expired-cleanup pass filter
by product.provider='Civitai'. Referral-granted subs (buzzType='referral')
point to Civitai-provider referral products, so without this guard the
prepaid cron would try to transition them using its prepaid-token logic
and cancel them for having no tokens. advanceReferralSubscriptions owns
those subs instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(referral): link dashboard from buzz + membership pages

ReferralCallout component with two variants:
- full: gradient hero card for the Buzz Dashboard top-of-page
- compact: slim row for the Membership page header

Both flag-gated on features.referralProgramV2 so the card vanishes when
the program is off. Links to /user/referrals.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(referral): hidden debug endpoint for UI experimentation

POST /api/testing/referrals guarded by the WEBHOOK_TOKEN header (same
pattern as /api/testing/strikes). Exposes a controlled surface for driving
the referrals dashboard without paying real money, instead of reaching
into the DB directly.

Actions: dump, bind-code, grant-tokens, grant-blue-buzz, enqueue-chunk,
simulate-membership-payment, simulate-buzz-purchase, simulate-chargeback,
settle-all, advance-subs, expire-tokens, reset.

settleImmediately=true on grant-* skips the 7-day wait. settle-all /
advance-subs / expire-tokens fast-forward the relevant cron for one
user. reset wipes a user back to a clean slate.

Typical flow + curl examples documented in docs/features/referral-program.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(referral): document debug endpoint usage

* docs(testing): establish debug-endpoint convention + header doc

Referrals debug endpoint now leads with a block comment enumerating each
action, required params, and a one-line description. That header is the
documentation — agents read the file directly instead of consulting a
wrapper skill.

CLAUDE.md gets a new section describing the convention so the same pattern
propagates to future features. Rule is: drop <feature>.ts under
src/pages/api/testing/, guard with WebhookEndpoint, lead with a block
comment, scope destructive actions to a single userId per call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(referral): 8 staged experiment scenarios + auth fix

Review doc section 7 adds a phase-by-phase experiment plan Justin can
walk through as the feature matures in review. Phase 1 seeded directly
via DB (6 settled Bronze tokens + 500 Blue Buzz display row on user 1)
so the dashboard walk-through works before this branch deploys.

Testing endpoint header updated: auth is ?token= query param (reading
from TokenSecuredEndpoint), not Authorization: Bearer. Doc + curl
examples corrected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(referral): A/B/C/D dashboard variants with switcher

Page now delegates rendering to one of four variants, picked via ?v= query
param. Parent owns data fetch, redeem mutation, shop modal; variants are
dumb components taking a shared ReferralDashboardVariantProps.

Variants:
- A · Current — baseline minimal (extracted from original referrals.tsx)
- B · Explainer — Buzz-dashboard-style narrative, how-it-works callouts,
  4-step earn/spend breakdown
- C · Gamified — level/rank badge by conversion count, milestone ladder
  as centerpiece, achievement feed framing for recent rewards
- D · Funnel — share → click → signup → first-paid → loyal stages, stages
  we can't track yet labeled "coming soon" instead of fabricated metrics

SegmentedControl at the top lets Justin flip between them side-by-side on
the real dashboard to pick a direction. Switcher removes itself once a
final variant is chosen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(referral): refined gamified dashboard

Picked variant C as the baseline per Justin's feedback and applied the
rework in one pass. All four variants + switcher removed.

Key changes:
- Recruiter rank driven by a unified score (conversions + lifetimeBlueBuzz/1000),
  mapped to named ranks Rookie / Recruit / Advocate / Champion / Legend
  at 0 / 1 / 10 / 50 / 200 points. Separates cleanly from the Blue Buzz
  milestone ladder (milestone bonuses still trigger purely on buzz thresholds).
- Rank card: big name, progress bar to next rank, three supporting stats
  (paid referrals, lifetime Blue Buzz, recruiter score).
- Milestone ladder: full formatted numbers, named milestones, Blue Buzz
  bolt icons throughout, per-row Unlocked/Up next/Locked badge, progress
  bar on the next-up row. Dismissible explainer alert (persists in
  localStorage).
- How-it-works card: 4-step explainer pulled from variant B (Share →
  Earn Tokens → Earn Blue Buzz → Spend Tokens).
- Token Bank: inline 6-offer grid replaces the modal. Redeem happens in
  place. Shop modal removed from the parent page.
- Recent referrals (renamed from Recent activity): kickbacks and recruits
  with amount on the right under status badge, cap 10 with Load more.
- Palette pared back to neutral dark accents + tier colors on the shop
  tiles only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(referral): dashboard polish pass

Addresses feedback from the first C rework:

- Bring color back: tier palette on shop headers (bronze/silver/gold
  gradients), accent-colored rank card (Rookie gray / Recruit teal /
  Advocate blue / Champion grape / Legend yellow), blue for Blue Buzz,
  yellow for milestone trophies, green for Settled badges.
- "Recruiter rank" -> "Your rank". Removed the redundant top-right rank
  badge. Rank name is colored by accent and paired with a trophy icon in
  a filled ThemeIcon.
- How it works is now dismissible (localStorage), uses FeatureCard-style
  layout with gradient icon headers (Share blue, Earn Tokens teal, Earn
  Blue Buzz yellow, Spend grape).
- Milestone explainer wording clarified: "Once a friend pays for any
  Membership with your code, every Buzz purchase they make earns you 10%
  back as Blue Buzz" — reflects the actual gate (firstPaidAt, no time
  window). Lifetime Blue Buzz promoted to a standalone pill with a large
  bold number and bolt icon, visible alongside the section title.
- Milestone rows: trophy icon + yellow filled ThemeIcon when unlocked,
  blue light ThemeIcon when Up next, muted when locked. Bonus amount now
  called out in yellow with a bolt icon.
- Token Bank: Spendable / Pending promoted to prominent tiles with icons
  (IconCoin / IconClock) and hint text ("Ready to redeem" / "Settles in 7
  days"). Shop regrouped by tier — 3 tier cards, each containing the 14d
  and 30d options as rows instead of 6 flat tiles.
- Recent referrals: big reward number with tier-colored icon per row.
- A few iconography upgrades across stats (IconSparkles for Recruiter
  Score, IconRocket for "Next rank" label).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(referral): blue-buzz bonus color, filled bolt, rank stats as cards

- Milestone bonus amounts + unlocked trophy/badge now blue (Blue Buzz
  color), not yellow. Yellow was misleading since bonuses pay in Blue
  Buzz, not yellow Buzz.
- Swapped IconBolt -> IconBoltFilled across the dashboard. Civitai never
  uses the outline variant elsewhere.
- Rank stats converted from a divider-separated left-aligned Group to a
  3-column Grid of RankStatCard tiles (icon block + label + value),
  matching the Token Bank / milestone pill styling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(referrals): rescale recruiter score for granular progress

- 1 point per Blue Buzz, 1,000 per paid referral month
- Thresholds: Rookie 0, Recruit 1k, Advocate 10k, Champion 50k, Legend 200k
- 500 BB now shows halfway to Recruit instead of stuck at 0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(referrals): restructure dashboard per feedback

- Move How It Works above Rank card
- Move referral timeline under Token Bank
- Token Bank: tier-colored accent header (no dark text on gradient),
  drop meaningless sparkles icon, add divider between duration rows,
  keep button filled when affordable / outlined default when not
- Token Bank subtitle: "Spend referral tokens on Membership perks."
- Add dismissible info alert describing token earning mechanics
- Migrate kickback + how-it-works + token-bank dismissals from
  localStorage to user-level dismissedAlerts (trpc.user.dismissAlert)
- Remove icon on Recent referrals header (only card with one)
- Simplify HowStep #4 copy (drop tier-queue phrasing)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(referrals): round 2 UI polish per feedback

- Rank card: progress bar uses next-rank color so it's visible
  against the subtle backdrop (previously gray on gray)
- Token Bank alert: switch color from orange to blue to match
  Blue Buzz milestone alert
- Tier cards: full-width dividers between duration rows
- Spendable/Pending tiles: drop hint text, add info tooltip
  (IconInfoCircle) next to label
- Recent referrals:
  - Add "Settles {date}" for Pending rows
  - Blue Buzz amounts in blue + bolt icon (drop "Blue Buzz" text)
  - Token amounts show coin icon (drop "tokens" text)
  - Icon backgrounds match tier color (bronze/silver/gold)
- Milestone bonus: bolt icon before amount (+[bolt]{num} bonus)
- How It Works cards: FeatureCards-style gradient header + circular
  icon to match Buzz dashboard style
- Timeline: move tier-queue note to bottom, style as crypto-deposit
  info row (IconInfoCircle + dimmed text)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(referrals): round 3 polish — premium code block + stats + spotlight

- Code block redesigned crypto-deposit style: gradient accent bar,
  spotlight hover, monospace code in a chip with ActionIcon copy,
  gradient "Copy share link" button, subtle background wash, socials
  under a divider with uppercase section label
- Blue Buzz milestones: replace lifetime-earned chip with Earned +
  Pending stat blocks matching rank card / token bank pattern
- Unify RankStatCard + TokenTile into single StatBlock (optional
  tooltip)
- Add spotlight hover effect to How It Works cards
- Rewrite How It Works copy: shorter, less dense, especially
  step 2 which was number-heavy
- Move Program Terms link to footer of the page

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(referrals): scope spotlight, relocate copy-link, add skeletons

- HowStep cards: spotlight now confined to the colored header (was
  spanning the whole card)
- Referral code block: drop the out-of-place top-right "Copy share
  link" button; move it into the Share It row as the first action
  (still gradient-styled, compact-sm to match siblings)
- Rank card: remove the radial glow accent — not used elsewhere in
  the dashboard, looked out of place above the other cards
- Replace loading spinner on /user/referrals with a full-dashboard
  skeleton (matches section structure: code, how-it-works, rank,
  milestones, token bank, recent referrals)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(referrals): tier perks popover, yellow tokens, premium rank card

- Shop tier cards: spotlight hover on the Paper, info bobble next
  to the tier title that opens a Popover with PlanBenefitList
  (monthly Buzz + default perks filtered by tier/buzzType). On green
  servers the popover also links out to /pricing for full details.
- Subscriptions/PlanBenefitList: rename default benefit
  "Generate mature content with Blue Buzz" →
  "Unrestricted generation with Blue Buzz" (appears on red/yellow
  servers only). Affects the referrals popover and the pricing page.
- Unify token color to yellow across the dashboard:
  - Token Bank Spendable stat: green → yellow
  - Recent referrals token icon: orange-5 → yellow-5
  - Shop card cost icon: default → yellow-5
- Redemption history reward format: "−1 tok" → "−[coin] 1"
- Rank card: redesigned premium style with spotlight, rank-colored
  gradient accent bar, subtle tinted background wash (matches the
  referral code block treatment)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(referrals): round 6 — inline value icons, bg fixes, popover portal

- StatBlock gains optional valueIcon prop rendered inline before
  the number; outer icon is now decorative only
- Blue Buzz milestones:
  - Earned: green accent, IconCircleCheck outer, IconBoltFilled inline
  - Pending: IconBoltFilled inline (outer IconClock unchanged)
- Lifetime Blue Buzz (rank card): outer icon → IconHistory, inline
  IconBoltFilled
- Token Bank:
  - Spendable: green accent, IconCircleCheck outer, IconCoin inline
  - Pending: IconCoin inline (outer IconClock unchanged)
- TierPerksPopover: `withinPortal` so the Gold popover is no longer
  clipped by the card's `overflow: hidden`
- Recent referrals: the leading "+" stays white regardless of
  reward color
- ReferralCodeBlock + RankCard: apply light-dark background so they
  stop blending with the page body in dark mode

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(referrals): points-driven milestones + auto-backfill

Replace blue-buzz-driven milestone progression with a unified Referral
Points metric: 1 pt per Blue Buzz earned + 1k/2.5k/5k per paid Bronze/
Silver/Gold referral month (≈10% of each tier's monthly Buzz value).

- constants.referrals.pointsPerTierMonth defines the tier weights
- computeLifetimeReferralPoints sums settled buzz + tier-weighted months
- awardMilestones now drives off lifetime points (same thresholds:
  1k/10k/50k/200k/1M)
- getReferrerBalance returns lifetimePoints + pendingPoints alongside
  the existing token/buzz fields (plus nextTokenExpiresAt and
  expiringSoonTokens for the use-them-or-lose-them UI)
- getDashboard fires awardMilestones(userId) fire-and-forget so a user
  whose lifetime points crossed a threshold under the new scoring gets
  the bonus written + payed out on next page load (idempotent via the
  existing unique (userId, threshold) constraint)
- getDashboard exposes activeMembership so the redeem confirm modal can
  warn before stacking on top of an existing paid plan
- getTierBonuses now returns rewardsMultiplierByTier and
  purchasesMultiplierByTier (skipping referral-grantable placeholder
  products so we reflect what real subscribers get); coerces the loose
  JSON multiplier values to number to avoid string surprises
- Testing endpoint grant-tokens defaults tokenAmount to the tier's
  canonical 1/2/3 when omitted so seeded data matches real flow

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(referrals): dashboard polish + flag gate

Big consolidated UI pass after Justin's feedback rounds 7-10.

Page + content
- pages/user/referrals.tsx: gated server-side on
  features.referralProgramV2 -> notFound; meta title aligned with
  buzz-dashboard pattern (Civitai | Refer & earn); skeleton fallback
  while data loads
- static-content/referrals/terms.md: frontmatter moved above the
  HTML placement comment so gray-matter parses the title correctly
  (terms page now shows the right title + description)

Dashboard
- "Refer & earn" hero with IconGift ThemeIcon; subtitle tightened
- Premium ReferralCodeBlock: plain left side (gray-50/dark white-3%),
  spotlight + subtle gradient on the right Share It panel, gradient
  divider between, share buttons natural compact-sm
- Friends-get blurb under the code: "Friends get 25% bonus Blue Buzz
  on their first Membership month"
- HowStep cards spotlight scoped to the colored header; rewritten
  copy; "Token Bank" -> "Token Shop" everywhere (title, step 4,
  alert key)
- Rank card: premium style w/ rank-colored gradient bar + spotlight,
  fallback default text color when rank is gray (rookie) so it reads
  in light mode
- Milestones renamed -> "Milestones"; driven by lifetimePoints with
  green Earned (IconCircleCheck) + gray Pending (IconClock) stat
  blocks; inline blue bolt next to BB amounts, violet star next to
  point counts; unlocked thresholds show a fully filled progress bar
- Token Shop: spendable (green) + pending (gray) stats with inline
  yellow coin icons; per-tier ShopTierCard with spotlight, tier-
  colored accent header, info popover that opens PlanBenefitList
  (tier-filtered), full-width dividers between duration rows; redeem
  triggers an openConfirmModal when the user has an active paid
  membership
- Spendable stat shows an IconAlertTriangle to the right of the
  number when tokens expire within 30 days; popover explains how
  many and earliest expiry
- Recent referrals: rows with bg-gray-50/dark white-3%, tier-colored
  icon backgrounds (bronze/silver/gold), settles-on date for pending,
  blue bolt + amount layout for BB, yellow coin + amount for tokens
- Redemption history: "−1 tok" replaced with "− [coin] 1"
- Tooltips switched to color="dark" so they stop reading white in
  light mode
- Scoring details popover (with table) replaces the inline scoring
  text; same popover reused on the Points stat block via an
  UnstyledButton info bobble that matches the rest of the dashboard
- Recruiter Score renamed -> Referral Score, IconAward outer +
  IconStarFilled inline
- Skeleton mirrors all of the above so the loading state matches
  the new layout
- ReferralCallout (Buzz dashboard entry): tileCard-style background
  matching neighboring cards; gift icon; copy without slashed tier
  list
- ReferralTimelineProgress: dark-color tooltips; tier-queue note
  moved below the bars in crypto-deposit info-row style
- dashboard.types.ts: computeRecruiterScore now passes through
  lifetimePoints (rank thresholds unchanged: 0/1k/10k/50k/200k)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(stripe): inline referral code + percent/multiplier perks copy

Move the referral-code entry to Stripe's hosted checkout via
custom_fields so it lives "in the same modal as checkout" (per
feedback: the previous pre-checkout banner felt awkward).

- createSubscribeSession adds a single optional text custom_field
  ("Referral code (optional)", key=ref_code). Cookie-driven refCode
  still flows through subscription metadata as before.
- checkout.session.completed handler (subscription branch) reads
  session.custom_fields[].text.value for ref_code, uppercases/trims,
  and patches the new Subscription's metadata. The existing
  manageInvoicePaid -> bindReferralCodeForUser /
  recordMembershipPaymentReward flow then picks it up unchanged.
- pages/pricing: drop the standalone ReferralCheckoutBanner since
  the field now lives on Stripe's checkout itself.

Cross-cutting perks copy:
- PlanBenefitList: "Generate mature content with Blue Buzz" ->
  "Unrestricted generation with Blue Buzz"; remove negative mx
  on the section divider so it stops blowing out of popovers.
- getPlanDetails: shared formatBoostCopy helper renders perk
  multipliers as "{pct}% bonus..." below 2x and "{n}x ..." at or
  above 2x; applied to both rewards and purchases multipliers; drop
  the isGreen-only gate so red shows the purchase multiplier when
  the tier metadata defines one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(referrals): correctness + abuse hardening from external review

External Gemini review of the reward distribution paths surfaced a
batch of correctness, race, and abuse issues. All addressed in this
commit, with vitest coverage. The dashboard copy + token-earning
popover from the same round are bundled in.

Backend fixes
- recordMembershipPaymentReward: open a $transaction and SELECT … FOR
  UPDATE the UserReferral row before reading paidMonthCount /
  firstPaidAt. Two concurrent invoice.paid webhooks for the same
  referee can no longer both create a RefereeBonus or both increment
  past the cap.
- manageInvoicePaid (stripe webhook): if subscription_details.metadata
  is empty, fall back to fetching the parent Checkout Session and
  reading custom_fields[].text.value for ref_code. Stripe doesn't
  guarantee checkout.session.completed lands before invoice.paid; this
  catches the "first invoice arrives first" race.
- ReferralReward.points column (new) snapshots the tier's point value
  at write time. computeLifetimeReferralPoints + getReferrerBalance
  now sum that column instead of dynamically multiplying historical
  membership token counts by the current pointsPerTierMonth.
  Re-tuning the constants no longer retroactively unlocks milestones.
  Migration backfills existing rows with their tier-canonical points.
- payment_intent.succeeded: drop the swallowed .catch(() => null) on
  recordBuzzPurchaseKickback so a transient DB error propagates and
  Stripe retries the webhook. Idempotency is already guaranteed by
  the @@unique(kind, sourceEventId) constraint.
- revokeForChargeback: per-referee tx that locks UserReferral via
  $queryRaw FOR UPDATE, decrements paidMonthCount by the count of
  revoked MembershipToken rows, and clears firstPaidAt to null when
  the count drops to 0. Closes the refund-and-keep-buzz-kickbacks
  abuse path. Lock prevents two concurrent chargebacks for the same
  referee from racing the read-modify-write.
- grantReferralSubscription: lock the CustomerSubscription row at the
  start of the tx so a concurrent advance-cron run can't overwrite a
  newly-appended queue entry.
- advanceReferralSubscriptions: run each sub through its own
  $transaction with FOR UPDATE on the row, re-check currentPeriodEnd
  inside the lock so a redemption that bumped the period out doesn't
  get clobbered.
- grantReferralSubscription enforces constants.referrals.maxQueuedDays
  (365). A hot referrer can't queue years of perks (Stripe's date max
  is 2038). Returns a user-facing error so redeemTokens rolls back
  and the spent tokens are not consumed.
- computeLifetimeReferralPoints + getReferrerBalance.lifetimePoints
  include Expired status — a token expiring shouldn't pull the user's
  lifetime points down (or boot them out of a milestone).

Dashboard copy
- Milestones info alert reworked to match how kickbacks actually work:
  earn points per paid Membership month + 10% of any Buzz the friend
  buys after joining. Cross a milestone for a bonus.
- Token Shop info alert simplified: "Earn tokens per Bronze (1 token),
  Silver (2 tokens), or Gold (3 tokens) month, up to three months
  per friend."
- New TokenEarningsPopover wired to the Spendable stat info bobble
  (mirrors the ScoringDetailsPopover pattern). Permanent reference for
  per-tier token amounts + the 3-month-per-friend cap + 90-day expiry,
  available even after the dismissable alert is closed.

Tests
- 35 vitest cases (was 24); all green.
- New: race-protected first-payment via locked paidMonthCount, cap
  enforcement after lock, inline RefereeBonus settle (success +
  buzz-grant-failure revert), revokeForChargeback decrement +
  firstPaidAt clear, RefereeBonus revoke does not touch UserReferral,
  paid months remain → firstPaidAt stays, FOR UPDATE used in
  revokeForChargeback (no findUnique fallback), advance bails when
  currentPeriodEnd is bumped out by a parallel redeem, points
  snapshot is immune to config changes, lifetime aggregate filter
  includes Expired.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:11:28 -06:00
Justin Maier bb7e9c7560 Model UI Overhaul - File Management & Download Experience (#1964)
* feat: [US-001] - Add quantType and componentType type definitions

- Add ModelFileQuantType type: 'Q8_0' | 'Q6_K' | 'Q5_K_M' | 'Q4_K_M' | 'Q4_K_S' | 'Q3_K_M' | 'Q2_K'
- Add ModelFileComponentType type: 'VAE' | 'TextEncoder' | 'UNet' | 'CLIPVision' | 'ControlNet' | 'Config' | 'Other'
- Add quantType and componentType to BasicFileMetadata interface
- Add quantType to UserFilePreferences interface
- Update preferenceWeight to use Partial<Record<...>> for type compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Restore model UI overhaul planning documents

Re-adds planning files from feature/model-ui-overhaul-plan branch:
- Main plan document with implementation details
- Proposal document with options analysis
- HTML mockups for sidebar and file upload UI

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: US-002 - Add quantTypes and componentTypes constants

Added modelFileQuantTypes and modelFileComponentTypes constant arrays
to src/server/common/constants.ts for use in UI selectors.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat: US-003 - Add quantType to file preference scoring

- Added quantType: 0.5 to preferenceWeight object for scoring
- Updated defaultFilePreferences to include quantType: 'Q4_K_M' as default
- FileMetaKey type already includes quantType from BasicFileMetadata
- Files with matching quantType now get 0.5 added to their score

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat: US-004 - Add quant type preference to user settings UI

- Added 'Preferred Quant Type' Select to SettingsCard.tsx
- Shows conditionally when format preference is GGUF
- Uses constants.modelFileQuantTypes for options
- Wired to user.filePreferences.quantType
- Added tooltip explaining quant types (Q8 = best quality, Q4 = balanced, Q2 = smallest)
- Default value: Q4_K_M (balanced quality/size)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat: US-005 - Update file service for quantType selection

Updated getFileForModelVersion to accept and handle quantType parameter.
Files with matching quantType will now be scored appropriately during file selection.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat: US-006 - Update download API for quantType parameter

- Added quantType to validation schema with Zod enum validation
- quantType is automatically passed through to file selection via spread operator
- Validated against constants.modelFileQuantTypes
- Misalignment check automatically handles quantType

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat: US-007 - Add quant type selector to file upload form

- Added quantType field to FileFromContextProps and SchemaError types
- Updated FilesProvider to initialize and handle quantType from metadata
- Added quantType to metadata upload payload in handleUpload
- Added quantType to modelFileMetadataSchema in server schema
- Added validation refine to require quantType for GGUF files
- Added conditional quantType Select in FileEditForm for .gguf files
- Added tooltip explaining quant types (Q8 = best quality, Q4/Q2 = smaller)
- Updated handleSave and handleReset to include quantType
- quantType is marked as required (withAsterisk) for GGUF files

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat: US-008 - Add component type selector to file upload form

Added componentType field to file upload form for non-Model file types.
Component type selector appears for VAE, Text Encoder, Config, and Archive files.
Auto-suggests componentType based on file type (VAE -> VAE, Text Encoder -> TextEncoder).

Changes:
- Added componentType to FileFromContextProps and SchemaError types
- Added componentType to modelFileMetadataSchema
- Added componentType Select to FileEditForm with conditional display
- Updated handleSave, handleReset, and handleUpload to include componentType
- Added auto-suggestion logic when file type changes

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat: US-009 - Restructure file upload UI into sections

- Restructured Files.tsx into three sections: Model Files, Required Components, Optional Files
- Added section headers with appropriate icons (IconFile3d, IconPuzzle, IconFileSettings)
- Model Files section: files with type 'Model' or 'Pruned Model'
- Required Components section: files with type 'VAE', 'Text Encoder' - yellow warning styling
- Optional Files section: files with type 'Config', 'Archive', 'Workflow', etc.
- Files auto-categorize based on type into appropriate section
- Yellow Card styling for Required Components section to indicate importance

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat: US-010 - Create Link Component Modal

Implemented LinkComponentModal for linking to existing models on Civitai as
required components. Users can now:
- Select component type (VAE, TextEncoder, UNet, CLIPVision, ControlNet)
- Search for models using QuickSearchDropdown
- Select version from model
- Select file from version
- Link component is saved and displayed in Files.tsx

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: [US-011] - Update FilesProvider validation for new fields

- Add component-only model validation: models without Model type files
  must have at least 2 required components (VAE, Text Encoder, UNet, etc.)
- Update conflict checking to include quantType in duplicate detection,
  preventing duplicate [size, type, fp, format, quantType] combinations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: [US-012] - Create file variant grouping utility

Add groupFilesByVariant() utility function that groups model files by:
- Format (SafeTensor, GGUF, other) for model files
- Component type (VAE, TextEncoder, etc.) for component files

Within each group, files are sorted by quality (best first):
- SafeTensor: fp32 > fp16 > bf16 > fp8 > nf4, full > pruned
- GGUF: Q8_0 > Q6_K > Q5_K_M > Q4_K_M > Q4_K_S > Q3_K_M > Q2_K

Also exports GroupedFileVariants type for consuming components.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: [US-013] - Update download button with variant dropdown

- Created DownloadVariantDropdown component for grouped file variant selection
- Groups files by format (SafeTensor, GGUF, Other)
- Shows 'Best match' badge on user's preferred file based on preferences
- Dropdown shows file size, precision/quant type for each variant
- Default selected file is user's preference match
- Integrated into ModelVersionDetails sidebar when multiple model variants exist

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: [US-014] - Add Required Components accordion section to sidebar

- Create RequiredComponentsSection.tsx with yellow warning styling
- Group component variants (e.g., Text Encoder fp16/fp8) with expandable lists
- Single-variant components show without dropdown
- Multi-variant components show expandable list with best match auto-selected
- Add Download button for each component
- Add "Download All Components" button that downloads preferred variants
- Update detailAccordions default to include required-components

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: [US-015] Handle component-only models in sidebar

- Detect component-only models (no model files, only components)
- Hide main download button for component-only models
- Show informational message: 'This is a modular model - download components below'
- Make 'Download All Components' the primary action (filled button, larger size)
- Keep Generate button visible for component-only models

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: [US-016] - Update FileInfo display for new metadata

- Show quantType for GGUF files in file info popover
- Show componentType for component files (VAE, TextEncoder, etc.)
- Added friendly display names for component types
- Handle missing metadata gracefully (only show if set)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: Fix prettier formatting in FileInfo.tsx

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: Fix prettier formatting in feature files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: Remove unused imports from RequiredComponentsSection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: [BUG-001] - Persist linkedComponents to FilesProvider context

Move linkedComponents state from local useState in Files.tsx to
FilesProvider context to ensure state persists across component
remounts and is available for form submission.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: [BUG-002] - Add componentType to duplicate file checking

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: [BUG-003] - Fix race condition in LinkComponentModal auto-selection

- Move auto-selection logic from render to useEffect hook
- Add versionsLoading check to prevent accessing files before data loads
- Add hasAutoSelectedFileRef to prevent multiple auto-selections
- Reset ref in handleBack to allow re-selection when navigating back

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: [BUG-005] - Agent review validation with null safety fix

Ran agent-review to validate BUG-001 through BUG-004.
Fixed issue identified in review: added missing toLowerCase()
for .zip extension check in FileInfo.tsx for consistency with
.gguf check.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: [PAT-003] - Verify form validation patterns

Audit of Zod validation patterns in FilesProvider.tsx - no code changes needed.
Pattern compliance verified: Zod schema extension, refine usage, showErrorNotification.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: [PAT-006] - Verify download button patterns

Audit completed for DownloadVariantDropdown.tsx patterns.
All patterns match established codebase conventions:
- createModelFileDownloadUrl usage matches other components
- formatKBytes usage consistent across codebase
- DownloadButton polymorphic pattern properly implemented

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: [PAT-008] - Run agent-review on pattern compliance

External agent review completed for all new/modified components:
- LinkComponentModal.tsx: PASS - modal, search, select patterns correct
- FilesProvider.tsx: MOSTLY PASS - found silent error catch issue
- Files.tsx: PASS - well-structured
- RequiredComponentsSection.tsx: PASS - with code duplication note
- DownloadVariantDropdown.tsx: PASS - with code duplication note
- SettingsCard.tsx: PASS - optimistic updates correct

Key findings documented:
- getFileLabel/getFileDescription duplicated across 2 files (non-blocking)
- Silent catch block in FilesProvider line 391 (non-blocking)
- All core patterns (modal, search, select, accordion, tRPC) followed correctly

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: [QUA-001] - Extract shared file helper functions

- Create ~/utils/file-display-helpers.ts with getFileLabel and getFileDescription
- Update DownloadVariantDropdown.tsx to import from shared utility
- Update RequiredComponentsSection.tsx to import from shared utility
- Remove duplicated function definitions from both files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: [QUA-002] - Fix silent error catch block

Add proper error handling to the empty catch block in FilesProvider.tsx.
Previously, errors from createFileMutation.mutateAsync were silently caught.
Now displays an error notification to the user matching established patterns.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: [QUA-003] - Check for unused imports and dead code

- Remove unused import MasonryScroller from masonic in Files.tsx
- Remove unused variables offset and resizeObserver in Files.tsx
- Fix unescaped apostrophe (We'll -> We&apos;ll) in Files.tsx
- Remove unused index parameter in map callbacks in Files.tsx
- Remove unused componentType prop destructuring in RequiredComponentsSection.tsx
- Remove unused modelType prop destructuring in DownloadVariantDropdown.tsx

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: [QUA-004] - Verify accessibility attributes

Added comprehensive ARIA attributes and keyboard accessibility:
- DownloadVariantDropdown: Added role="button", aria-expanded, aria-haspopup,
  aria-label, tabIndex, keyboard handler to dropdown trigger; role="listbox"
  to dropdown content; role="option", aria-selected, aria-label to VariantItem
- RequiredComponentsSection: Added role="button", aria-expanded, aria-label,
  tabIndex, keyboard handler to expandable component headers; role="listbox"
  to variant list; role="option", tabIndex, aria-selected, aria-label,
  keyboard handler to file selection boxes
- LinkComponentModal: Added aria-label to QuickSearchDropdown and Select
  components for search, version, and file selection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: [QUA-006] - Verify useEffect cleanup

- Added useMemo to the `files` variable in LinkComponentModal.tsx to maintain
  reference equality for useEffect dependencies
- Fixes ESLint react-hooks/exhaustive-deps warning about the files logical
  expression causing useEffect dependencies to change on every render
- Reviewed all useEffect hooks in LinkComponentModal.tsx, FilesProvider.tsx,
  and DownloadVariantDropdown.tsx for proper cleanup
- Verified no async operations/subscriptions/timers require cleanup in these files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: Remove accidentally committed temp and ralph project files

- Remove ralph phase2-pattern-reuse prd.json and progress.txt (should be gitignored)
- Remove tmpclaude-* temp files
- Add tmpclaude-* pattern to .gitignore

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fixes typecheck issues

* feat(model-ui): overhaul model sidebar with variant downloads, components, and actions

- Restructure primary actions card with consolidated icon row (generate, share, like, vault, civitai link, notifications, collect, bid, report)
- Add DownloadVariantDropdown with grouped file sections (SafeTensor/GGUF/Other) and best-match badge
- Add RequiredComponentsSection with component variant selection and Download All
- Persist linked components via RecommendedResource with isLinkedComponent flag
- Overhaul PermissionIndicator to show all permission badges (commercial, generation, credit, merges, license, NSFW)
- Handle early access/Buzz pricing in download and component sections
- Expand upload UI component filter to include UNet, CLIPVision, ControlNet
- Remove dead LinkComponentModal code (keep type export only)
- Fix: side effect in setState updater causing duplicate API calls
- Fix: memoize filesVisible to prevent groupFilesByVariant recomputing every render
- Fix: stagger multi-download to avoid browser popup blocking
- Fix: gate download hrefs on canDownload for early access models
- Fix: add staleTime to getFollowingUsers query to reduce unnecessary refetches

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Includes design file

* feat: model sidebar UI polish - verification status, button styling, reviews

- Add VerifiedText scan status to all file variants (download dropdown, required components, optional files)
- Remove redundant VAE alert banner from ModelFileAlert (now in RequiredComponentsSection)
- Hide Bid button for unpublished/deleted model versions
- Restyle Generate button as primary CTA with gradient background
- Make download button styling consistent (light blue secondary CTA) for single and multi-variant
- Update VerifiedText to match design (smaller text, colored icons, no ThemeIcon wrapper)
- Fix VerifiedText click propagation preventing variant toggle on popover click
- Fix light mode hover backgrounds on variant rows for readability
- Replace ResourceReviewThumbActions with ModelVersionReview in Details card

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add isRequired to file metadata & merge upload UI sections

- Add `isRequired` field to BasicFileMetadata, modelFileMetadataSchema,
  and linkedComponentSettingsSchema
- Move LinkedComponent type from LinkComponentModal.tsx to model-file.schema.ts
- Replace hardcoded 3-way file split (Model/Required/Optional) with
  dynamic 2-way split (Model Files + Additional Components) driven by
  acceptedModelFiles per model type
- Add Switch toggle for required/optional on FileCard and LinkedComponentCard
- Smart defaults: component types (VAE, Text Encoder, etc.) default to required
- Update groupFilesByVariant() to use metadata.isRequired for sidebar grouping
- RequiredComponentsSection now data-driven (no hardcoded component type list)
- Update validation to use isRequired for component-only model checks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Updates design

* fix: restore buzz earned and generation popularity metrics to model version details

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: enrich linked components at read time & add backend link creation

- Batch-fetch ModelFile data (name, sizeKB, type, metadata) in both
  controllers to enrich linked components at read time, fixing stale
  data for older records
- Add addLinkedComponent mutation that resolves file details server-side,
  eliminating the extra modelVersion.getById fetch from the frontend
- Remove sizeKB from linkedComponentSettingsSchema (no longer persisted)
- Add fileType/fileMetadata to LinkedComponent for correct download URLs
- Update RequiredComponentsSection and ModelVersionDetails to use file
  metadata for download URLs instead of user preferences
- Display file size next to download button consistently across all
  component types
- Add UNet, CLIPVision, ControlNet to modelFileTypes and modelFileOrder
- Various UI polish: icon swap, border removal, no-wrap on file sizes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: guard download buttons behind purchase check for optional/linked files

Optional files and linked components in both ModelVersionDetails and
RequiredComponentsSection were missing canDownload guards, allowing
direct downloads on early-access models that require purchase. Also
removes unused downloadPrice prop from ComponentGroup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Address PR review: type safety, null handling, and shared constants

- Add componentFileTypes constant to DRY up hardcoded arrays in 3 locations
- Tighten componentType schema validation from z.string() to z.enum()
- Prevent null quantType via allowDeselect={false} on SettingsCard Select
- Fix groupFilesByVariant to default isRequired to true (isRequired !== false)
- Normalize component-only validation to match linked component convention
- Remove unused RecommendedResourceSettings import

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Correctly filters out incompatible resources when linking components

* Fix model UI feedback: share popover, download URLs, resource select, upload errors

- Add withinPortal to ShareButton Popover to fix clipping in sidebar
- Use primary download URL for linked components to avoid 404 misalignment
- Default resource select to 'all' tab for modelVersion linking and skip
  empty recommendedModels filter to prevent spinner
- Improve file upload rejection messages with supported types and max file
  limit notifications

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Port resource select fixes to new ResourceSelectModal component

Apply the same fixes from the deleted ResourceSelectModal2.tsx:
- Default to 'all' tab when selectSource is 'modelVersion'
- Skip empty recommendedModels filter to prevent spinner

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Replace useEffect with conditional state for modelVersion tab default

Use local useState instead of localStorage-backed state when
selectSource is 'modelVersion', avoiding the stale tab problem
without a useEffect override.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fixes bad download url for required and optional components

* Fix Download All Components to download multiple files via hidden iframes

The previous implementation used forEach + setTimeout which broke the
browser's user-gesture trust context, causing only the first file to
download. The model download endpoint uses res.redirect(), so programmatic
<a> tag clicks get blocked after the first navigation. Hidden iframes each
follow redirects independently in their own browsing context. Also adds
loading state to the download button.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Migrate vaeId to linked components system

Replace the legacy ModelVersion.vaeId FK with the linkedComponents
system (RecommendedResource table). This unifies VAE linking under the
same mechanism used for all other component types (TextEncoder, UNet,
CLIPVision, etc.).

- Add SQL migration to create RecommendedResource records for existing vaeId refs
- Add getLinkedVaeIds helper for batch VAE resolution from linked components
- Update model controller, file service, generation service, and public API
- Replace raw SQL vaeId column refs with RecommendedResource subqueries in caches
- Generalize file download to support all linked component types, not just VAE
- Remove vaeId from selectors, schemas, and frontend form
- Remove duplicate VAE display in model detail (linkedComponents already shows it)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Removes TextualInversion and Hypernetwork filters when selecting linkedComponents

* Retrigger pr preview deployment

* Address model upload wizard UI feedback

- Add Workflow and Upscaler to file types, component types, and file order
- Expand GGUF quant types from 7 to 25 (K-quants, legacy, I-quants)
- Allow .gguf files in Checkpoint additional components (for quantized text encoders)
- Fix .zip inference: no longer auto-assigns type, user must pick
- Fix .zip format mapping from 'Diffusers' to 'Other'
- Add .json default inference to 'Config'
- Add ComfyUI-friendly display labels (CLIP / Text Encoder, UNet / Diffusion Model, etc.)
- Add Upscaler and Workflow to linked component resource selector
- Improve validation error surfacing with per-file toast notifications
- Add red border highlight on file cards with validation errors
- Make quant type select searchable for the expanded list

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Update quantQualityRank with expanded GGUF quant types

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add Consolidate Versions feature for merging multi-version models

Allow creators to merge multiple model versions into a single version,
moving all files into the target version with configurable file type
mapping and aggregating stats (downloads, likes, etc.) from all source
versions. Triggered from the model detail page context menu.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update import path for ReportEntity to use shared utils

* feat: enhance ConsolidateVersions UI and support file metadata updates

Overhaul the ConsolidateVersions modal with improved theme-aware design,
file metadata editing (fp, size, format, quantType, isRequired), and
updated schema/service to persist metadata changes during consolidation.
Also pass quantType and isRequired through getModelsWithVersions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: GGUF validation, .bin file type options, and required toggle reset

- Skip fp (precision) validation for GGUF checkpoint files since they use
  quantType instead — the UI shows Quant, not Precision, for GGUF files
- Allow .bin files to be assigned component types (VAE, UNet, etc.) in the
  file type dropdown, not just Model/Negative
- Stop resetting the Required toggle when changing file type in Additional
  Components — preserve the user's manual selection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove DownloadHistory reference from consolidateVersions

The DownloadHistory table no longer exists in production — downloads
are now tracked in ClickHouse. The raw SQL query was causing a 42P01
error when consolidating versions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: expand file upload types, selective version merging, rename consolidate to merge

- Allow config-type files (.json, .yaml, .yml, .txt) as primary uploads for
  Workflows, Poses, Wildcards, and Other model types
- Add .safetensors to Detection primary extensions
- Ensure all model types accept archive + config in additional components
- Extract shared filterFileTypeByExtension to file-display-helpers.ts
- Make inferFileType model-type-aware for correct .zip default assignment
- Fix additional dropzone defaultType bug (was always using first type)
- Add selective source version picking to merge versions wizard
- Rename ConsolidateVersions to MergeVersions across frontend and backend

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove debug console.log referencing ctx.isGreen in trpc middleware

Stray debug log was accessing ctx.isGreen which doesn't exist on the
context type — isGreen is a feature flag on ctx.features, not ctx directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: skip type inference for additional component drops, add Diffusion Model file type

Files dropped in the Additional Components section no longer auto-infer as 'Model' type,
letting users pick the correct component type (VAE, UNet, etc.). Split grouped labels
"CLIP / Text Encoder" → "Text Encoder" and "UNet / Diffusion Model" → separate "UNet"
and "Diffusion Model" options.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: reorganize model sidebar cards for better UX flow

Move resource review card after the accordion so it no longer interrupts
between download and model details. Group download-related alerts near
the download card. Move donation goals below review as a secondary CTA.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(model-upload): add UNet and Diffusion Model to Checkpoint primary file types

Move UNet and Diffusion Model from the additional components section to
the primary model files section for Checkpoint models, so users can
select these types directly from the model file type dropdown.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: manuelurenah <manuel.ureh@hotmail.com>
2026-04-22 13:35:05 -04:00
Briant Diehl 62f543cd3b Merge pull request #2186 from civitai/feature/generation-presets
add generation presets
2026-04-21 17:30:52 -06:00
Briant Diehl 06cf4abd02 add generation presets 2026-04-21 17:14:45 -06:00
Justin Maier a081a771f7 feat(articles): add moderatorNsfwLevel override with edit-form picker (#2183)
* feat(articles): add moderatorNsfwLevel override and recompute-on-save

Moderators previously had no way to force an article's rating to a
specific level. Editing the maturity picker wrote to userNsfwLevel,
but (a) upsert never triggered a nsfwLevel recompute - only the scan,
text-moderation, and report webhooks did - and (b) even after a
recompute the GREATEST-based derivation meant a mod could only raise
the rating, never lower one that had been overshot by scans or a
heavy-handed floor. Ally flagged an article stuck at R with no path
to bring it back down.

Add a nullable moderatorNsfwLevel column that takes precedence over
the full derivation. updateArticleNsfwLevels now writes
COALESCE(moderatorNsfwLevel, GREATEST(userNsfwLevel, cover, content,
moderation_floor)), so non-null pins the rating bidirectionally and
null falls back to the existing auto-derivation path.

upsertArticle now:
- Strips moderatorNsfwLevel from non-moderator payloads (silently;
  the picker never ships to owners).
- Diffs the incoming moderatorNsfwLevel and userNsfwLevel against the
  persisted values to decide whether to trigger updateArticleNsfwLevels
  post-commit, closing the gap where direct edits to those inputs
  previously sat dormant until an unrelated webhook kicked recompute.
- Auto-manages lockedProperties: setting an override locks the owner's
  userNsfwLevel picker, clearing it unlocks.

Form gets a mod-only InputSelect with an "Auto (derived from content)"
option for clearing the override, plus a blue alert that surfaces the
override to the owner and tells them their own preference is preserved.
The existing yellow "images raised it above your preference" alert is
gated on moderatorNsfwLevel being null so the two don't stack.

Migration adds the nullable column; search-index + card-layer cover
lifts already read article.nsfwLevel and inherit override behavior
without change.

* refactor(articles): consolidate recompute + scope moderatorNsfwLevel

Post-merge cleanup and Copilot review feedback on PR #2183:

- upsertArticle: drop the conditional `shouldRecomputeNsfwLevel` /
  `userNsfwLevelChanged` recompute hook — it's subsumed by the
  `updateArticleImageScanStatus([id])` call that now runs unconditionally
  post-commit (from the main-branch leak fix) and does the same
  recompute under an advisory lock alongside ingestion recompute. Keeps
  `moderatorOverrideChanged` since it still drives the
  `lockedProperties` pin/unpin.
- articleDetailSelect: remove `moderatorNsfwLevel` from the shared
  select. Copilot flagged that this shape is reused by the outbound
  webhook job (spreads `...article` into the third-party payload) and
  the Meilisearch indexer (spreads into the index document), so keeping
  the override in the shared select would leak an internal moderation
  signal to external consumers. Added a comment at the selector
  documenting the intentional omission.
- getArticleById / getModeratorArticles: extend the select locally with
  `moderatorNsfwLevel: true` so the edit form and mod list still get
  the field they need to render the override UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: manuelurenah <manuel.ureh@hotmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:19:25 -04:00
Justin Maier cf37a19a20 feat(buzz): DB-backed Rewards Bonus Events with moderator UI (#2182)
Replaces the REWARDS_BONUS_MULTIPLIER Flipt flag with a first-class
RewardsBonusEvent model so mods can schedule, name, describe, and link
bonus events without a redeploy. Admin UI at /moderator/rewards-bonus-events.

Multiplier stored as int * 10 (15, 20, 30, 40); active event picked by
highest multiplier within the enabled start/end window. Site-wide banner
picks up the event's optional bannerLabel; info modal surfaces name,
description, and a Learn more link to an attached article (relative /articles/{id}
so it works on both civitai.com and civitai.red).

In-process 5 min cache over the enabled-events list, with time-boundary
filtering evaluated on every request so scheduled starts/ends take effect
immediately instead of waiting on TTL. Invalidated on upsert/delete, and
the banner query is invalidated client-side after moderator saves so the
editor sees changes without a refresh.

Dates picked in the mod UI are stored as UTC midnight / UTC end-of-day
regardless of the moderator's browser timezone, mirroring the challenges
event pattern (toDisplayUTC / fromDisplayUTC + dayjs.utc startOf/endOf).
Schema refine rejects inverted windows.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 12:08:03 -06:00
Justin Maier e439ef6ac3 feat(collections): bucket-based nsfwLevel rollup
- Two-probe EXISTS scan (safe=1, nsfw=28; Blocked excluded)
- Precedence: forcedBrowsingLevel > item scan
- Collection.nsfw ignored — auto-flipped by user NSFW reports, unreliable
- Re-enable propagation from image/post/model/article updates to parent collections
- Trigger: re-scan on availability / read / metadata.forcedBrowsingLevel change
- Scope restricted to read IN ('Public','Unlisted')

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 17:18:03 -06:00
Briant Diehl 45be69c4bb pick upscale model in generation panel
(cherry picked from commit 7167c8d2fc)
2026-04-13 20:35:55 -06:00
Manuel Emilio Urena ed82ade5a9 feat: article ingestion status + manual rescan (#2158)
* feat: add article ingestion status and manual rescan

Adds ArticleIngestionStatus enum (Pending/Scanned/Blocked/Error) to gate
article visibility by scan state, separating scan state from publication
state. Non-owner/non-mod article reads now filter on ingestion = Scanned.

Also adds a user-triggered rescan feature: article owners and moderators
can manually re-trigger image scanning and text moderation from the
article detail page, with per-article rate limiting (3/day for owners,
unlimited for mods).

Key changes:
- New Article.ingestion column with migration and serving-path gates
- rescanArticle() service: resets ingestion, re-queues images, clears
  content hash and re-submits text moderation
- article.rescan tRPC mutation with isOwnerOrModerator middleware
- Rescan menu item in ArticleContextMenu + button in ArticleScanStatus
- Search index sync on ingestion state changes via recomputeArticleIngestion
- Text moderation webhook integration for ingestion recomputation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR feedback on article ingestion and rescan

- Guard contentScannedAt to only set on transition to Scanned, preserving
  the original scan-completion timestamp for observability
- Queue search index update immediately in rescanArticle so the article
  is not searchable while rescanning
- Gate Rescan menu item behind articleImageScanning feature flag
- Use ArticleIngestionStatus.Rescan (instead of Pending) for user-triggered
  rescans and content-edit rescans to distinguish from initial pending scans

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:29:04 -04:00
Manuel Emilio Urena 1ef4313251 Scan article embedded images (#1879)
* Adds implementation docs

* Adds support for parsing and scanning embedded article images

* Fixes scanning and publishing after processing article images

* Shows processing modal for updated article

* Show problematic images on article scan with actions

* Improves article image scan migration, adds feature flag and contentScannedAt migration

* Adds feature flag check on image-scan-result

* Cleanup docs

* More cleanup to docs

* Fixes OOM issues with migration script

* Fixes after merging with main

* Updates article image scan to use new ingestion pipeline

* Fixes image ingestion step for article migration script

* refactor: replace JSDOM-based image extraction with tiptap-based getContentMedia

- Extend getContentMedia to handle both media nodes (edge-media) and image
  nodes (img tags) with video support, replacing extractImagesFromArticle
- Remove manual ingestImage calls from migration script — DB trigger
  (trg_image_scan_queue) handles queueing automatically
- Remove JSDOM dependency (jsdom, @types/jsdom) and article-image-helpers.ts
- Fix ESM build error caused by JSDOM leaking into Next.js page bundles
- Add unit tests for extractCloudflareUuid, isValidCivitaiImageUrl,
  getContentMedia, and getContentImageUrls

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add xGuard text moderation for articles

Submit article title + content to xGuard for ML-based text moderation on
create/update. Webhook handler maps triggered labels to NsfwLevel, elevates
userNsfwLevel (never lowers), and auto-unpublishes blocked articles.
Migration script extended with mode param (images|text-moderation|both)
for backfilling existing articles. Docs renamed and updated to reflect
both image scanning and text moderation pipelines.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(text-moderation): pass rating labels to xGuard and use direct NsfwLevel mapping

- Add labels param to submitTextModeration and forward to orchestrator
- Pass rating labels (pg, pg13, r, x, xxx, nsfw) for article moderation calls
- Replace mapTriggeredLabelsToNsfwLevel with direct NsfwLevel enum mapping from triggered rating labels
- Remove old category-based labelNsfwMap (no longer needed)
- Respect dryRun in migration script text-moderation mode
- Temporarily disable article nsfwLevel updates in webhook to validate EntityModeration data

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(articles): update linkArticleContentImages to return orphaned IDs and support cleanupOnly mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(articles): update upsertArticle to handle orphaned image cleanup via deleteImageById

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(articles): delete content Image records on article deletion via deleteImageById

Replace deleteArticleContentImages (S3-only cleanup) with deleteImageById loop
so that Image DB records, S3 files, and caches are all cleaned up when an article
is deleted. Content image IDs are collected from ImageConnection before the
transaction removes those rows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(articles): remove deleteArticleContentImages and getContentImageUrls (replaced by deleteImageById)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(articles): add retry-failed-moderation mode and composable mode param

Change `mode` from single enum to comma-delimited array so modes can be
combined freely (e.g. `mode=text-moderation,retry-failed-moderation`).
Add `retry-failed-moderation` to re-submit articles with Failed/Expired
EntityModeration status. Also guard against undefined response in
orchestrator service.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(articles): rework migrate-article-images with separated mode processors

- Extract each mode (images, text-moderation) into its own function with
  dedicated dataProcessor, eliminating interleaved if/else logic
- Fix stats race condition: each batch processor now collects into local
  stats and merges after completion, preventing lost updates from
  concurrent dataProcessor batches
- Fix extraction-failed articles being silently marked as scanned by
  tracking failedArticleIds separately
- Merge retry-failed-moderation into text-moderation mode — now targets
  all articles without a Succeeded EntityModeration status
- Limit text moderation concurrency via limitConcurrency to avoid
  overloading the orchestrator
- Use Set for missing URL lookups (O(1) vs O(n) array scan)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Adds wait time when submitting text moderation requests

* refactor(text-moderation): simplify to nsfw-only scanning and split article backfill endpoint

- Reduce xGuard labels to ['nsfw'] across article create/update, retry job, and backfill
- Split article text-moderation backfill into dedicated endpoint with flat batch-by-batch concurrency using pgDbRead.cancellableQuery
- Strip text-moderation mode from migrate-article-images.ts (now image-scan only)
- Simplify text-moderation-result webhook Article handler to flip nsfw flag only (elevation-only)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(articles): tighten nsfwLevel filtering across serving paths for green rollout

- Fold Article.nsfw into nsfwLevel via CASE WHEN (matches the existing
  updateModelNsfwLevels/updateBountyNsfwLevels pattern): when the text scan
  flags an article, its level is written as nsfwBrowsingLevelsFlag so the
  standard bitwise AND filter handles it without new read-side logic.
- Swap bit_or -> max for cover + content image aggregation in
  updateArticleNsfwLevels. Since NsfwLevel values are powers of 2, integer max
  is the correct "highest rating" semantic. Eliminates the multi-bit leak
  class where articles mixing PG + NSFW images produced bitmasks retaining the
  PG bit and slipped through (nsfwLevel & publicBrowsingLevelsFlag) != 0.
- Close serving-path leaks that previously bypassed any nsfwLevel filter:
  getCivitaiNews now AND's publicBrowsingLevelsFlag into its raw SQL;
  getCivitaiEvents passes it through to getArticles; getArticleById accepts
  an optional browsingLevel and applies a post-fetch bitwise check for
  non-owner/non-moderator callers.
- article.getById router derives browsingLevel from ctx.features.canViewNsfw
  so civitai green force-hides NSFW articles on the detail endpoint without
  relying on client-side SensitiveShield.
- Articles sitemap: replace hardcoded NsfwLevel.PG with publicBrowsingLevelsFlag
  for semantic consistency with the other serving-path fixes.
- Remove createProfanityFilter + filterSensitiveProfanityData from the
  article path (upsert write block, getArticleById read-side hygiene,
  ProfanityEvaluation type imports, ArticleMetadata.profanityMatches +
  profanityEvaluation fields). The orchestrator text-moderation scan fully
  supersedes the lexical profanity check.
- /api/og.tsx fetchArticleData: keep title/content/stats visible when the
  cover image is NSFW, letting getSafeImage + buildEntityImage(null) fall
  through to the LogoPlaceholder template path instead of blanking the
  whole OG card.
- Open articles feature flag to ['public'] (was ['blue','red','public']) so
  civitai green can serve articles now that nsfwLevel filtering is correct
  across every surface.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(migration): update default values for batchSize and concurrency in query schema

* fix(migration): drop articles entity from profanity backfill

ArticleMetadata no longer carries profanityMatches/profanityEvaluation
(removed in 28b38c7dd when orchestrator text-moderation superseded the
lexical profanity check for articles), so the articles branch here
failed typecheck. Models and bounties still use the profanity filter,
so they remain.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(text-moderation): re-enable entity handler dispatch on scan success

EntityModeration data has been validated; uncomment the handler
dispatch so success results actually flow into the entity-specific
handlers (article scan backfill included) instead of no-oping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(articles): add client/server media extraction parity test

Locks in equivalence between the server-side `getContentMedia`
(tiptap-based) and client-side `extractImagesFromArticle` (DOMParser-based)
extractors so drift between the two surfaces at CI time instead of as a
UI/DB disagreement in production. Adds `happy-dom` as a devDep to
polyfill `DOMParser` in the node test environment without disturbing
`@tiptap/html/server`'s internal happy-dom instance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs(articles): add rollout rundown and ingestion-status proposal

`article-scanning-rollout.md` captures deploy-time context, gotchas,
and the deltas since the companion architecture doc, including the
§5.9 legacy-Green exposure window surfaced during the 2026-04-10
review pass.

`article-ingestion-status-proposal.md` specs out the
`ArticleIngestionStatus` enum + column modeled after `Image.ingestion`
as the architectural fix for §5.9 — unifies image and text scan
gating under a single serving-path predicate so legacy articles
self-gate on a per-row basis rather than requiring a staged flag flip.

Both paths (operational mitigation vs ingestion refactor) are
documented for the next session to pick up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 18:37:37 -04:00
Briant Diehl c51d875d16 fix migration 2026-04-10 13:19:41 -06:00
Briant Diehl c52b4fe5b9 Merge pull request #2146 from civitai/feature/green-auctions
Support Auctions on Civitai Green using Green Buzz
2026-04-10 12:27:39 -06:00
Briant Diehl cd5ae829d0 fix to migrations 2026-04-10 12:25:46 -06:00
Briant Diehl d4242c4839 added accountType to Bid and BidRecurring id signature 2026-04-10 12:18:23 -06:00
Luis Rojas 809c0640da Support Auctions on Civitai Green using Green Buzz 2026-04-07 22:39:15 -04:00
Briant Diehl 2f2b91a602 Merge branch 'main' of https://github.com/civitai/civitai into main 2026-04-07 12:51:35 -06:00
Briant Diehl 12faf69b3c job to retry text-moderation 2026-04-07 12:50:52 -06:00
Manuel Emilio Urena 99a99bd910 fix: resolve follower notification timeout death spiral (#2140)
* fix: resolve follower notification timeout death spiral

The new-model-from-following notification query has been timing out since
April 3-4 due to a missing Model(status, publishedAt) index causing 620K+
row scans. When the query times out, the cursor never advances, widening
the window on each retry and guaranteeing further timeouts.

Three-part fix:
- Add Model(status, publishedAt) index to eliminate full table scan
- Cap notification query window to 10 minutes with incremental catch-up
- Add 59-second lookback buffer to new-model-from-following query

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: add comment explaining 59-second lookback buffer in notification query

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add context to safety valve log for easier operational debugging

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:58:35 -04:00
Briant Diehl 2fc9c543c8 add text moderation 2026-04-06 16:18:51 -06:00
Justin Maier 84b07c5bd7 Redesign NowPayments reconciliation with sliding window and retry sweep
- Replace fixed 24h window with cursor-based sliding window using getJobDate
  (stored in KeyValue table, defaults to now-24h on first run)
- Catch grantBuzzPurchase errors in processDeposit and upsert CryptoDeposit
  with status='buzz_failed' so deposits are never lost
- Handle 409 duplicate transaction as success (webhook/reconciliation race)
- Enforce status ordering: confirming can't overwrite finished/buzz_failed
- Add retry sweep: each reconciliation run retries buzz_failed deposits
  (re-fetches from NP API for fresh data, max 72 retries = 12 hours)
- Throw on null NP API response during pagination instead of silently stopping
- Add retryCount column and status index to CryptoDeposit
- Fix test assertions: PAGE_SIZE=500, page starts at 0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 12:25:41 -06:00
Luis Rojas 0b8975e3c7 feat: comic creator — project-scoped references, layouts, speech bubbles, and UX polish
- Project-scoped references: new ComicProjectReference junction table with
    migration and backfill. References are now per-project with import modal
    for bringing in references from other projects. Sidebar shows remove
    (from project) vs delete permanently (with confirmation) actions.

  - Pre-made panel layouts: 8 PNG layout images shown as horizontal scrollable
    picker at top of Generate tab, included as reference images during generation.
    Layout consumes an image slot. Zoom preview on each thumbnail.

  - Speech bubbles: 4 PNG stamps added to DrawingEditor toolbar as a popover.
    Clicking adds a resizable/draggable image element on the canvas.

  - Multi-image polish: AwaitingSelection panel status (new DB enum + migration),
    CandidateImageModal with pre-select/confirm and zoom, polling mutex to
    prevent concurrent requests, panels no longer auto-open selection modal.

  - Duplicate guards: only Ready panels with images can be duplicated, chapters
    with incomplete panels are blocked. Confirmation dialogs and loading states
    on both panel and chapter duplicate.
2026-03-27 00:07:59 -04:00
Luis Rojas 7157c8ce92 feat: comic creator — duplicate panel/chapter, multi-image generation, reference panel picker
- Duplicate panel/chapter: new tRPC mutations with position shifting,
    confirmation dialogs, loading states, and guards (only Ready panels,
    no incomplete chapters). Context menu items in PanelCard and chapter sidebar.

  - Multi-image generation: quantity selector (1-4) in PanelModal, new
    AwaitingSelection panel status with DB migration, CandidateImageModal
    with pre-select/confirm flow and zoom preview, selectPanelImage mutation.
    Panels show yellow pulsing outline when awaiting selection.

  - Reference panel picker: replaced includePreviousImage boolean switch
    with ReferencePanelPicker component — horizontal scrollable thumbnails
    of all Ready panels in the chapter. Server validates referenced panel
    belongs to same project and has an image.

  - Polling hardened: added mutex to prevent concurrent polls, handle
    zero-candidate edge case (marks panel Failed), removed console.logs.
2026-03-26 23:16:54 -04:00
Manuel Emilio Urena dd07c79248 fix: prevent challenge winner cooldown bypass via image deletion (#2120)
* fix: prevent challenge winner cooldown bypass via image deletion

ChallengeWinner.imageId FK used ON DELETE CASCADE, so deleting a winning
image removed the winner record and allowed users to bypass cooldown.

Changed to nullable imageId with ON DELETE SET NULL so winner records
survive image deletion and cooldown enforcement remains intact.

Fixes #2119

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: keep all winners on retry path, gate UI on both imageId and imageUrl

Address review feedback:
- Retry path no longer filters out winners with null imageId, preventing
  missed prize payouts/notifications when image deleted between runs
- WinnerPodiumCard gates image block on both imageId and imageUrl for
  defensive safety

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add placeholder for deleted winner images on podium card

Shows an "Image removed" placeholder with IconPhotoOff when a winner's
image has been deleted, instead of leaving a gap in the podium.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 12:18:59 -04:00
Luis Rojas 8fc3c10536 Limit requests based off of generator queue. Add jobs to handle enqueued jobs from the smart create 2026-03-19 19:05:56 -04:00
Luis Rojas 068827d49a Seadream support + Download support 2026-03-17 18:10:24 -04:00
Justin Maier 591f4a8570 feat: simplified multi-chain crypto deposits with permanent addresses (#2105)
* feat(crypto): simplified crypto deposit system with permanent addresses

Replaces the old per-payment NowPayments flow with permanent deposit addresses
per user. Adds deposit history with live signal updates, currency selector with
fiat preference persistence, conversion rate display, and skeleton loading states.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(crypto): review pass — DRY, security, performance, accessibility

DRY: Extract shared FIAT_OPTIONS, outerCardStyle, getFiatDisplay() into
crypto-deposit.constants.ts. Create reusable FiatMenu component.

Security: Remove webhook secret from error logs, add input bounds on
perPage/page (max 25), cap concurrent API calls at 10.

Performance: Replace spotlight setState with ref-based DOM manipulation,
lazy-load QR code via next/dynamic, remove duplicate signal listener,
stabilize callback refs, add staleTime to deposit history query.

Backend: Fix broken reprocess-order endpoint, add division-by-zero guard
in getBuzzConversionRate, replace `as never` Redis key casts with proper
typing via paymentCacheKey helper.

Accessibility: Add aria-labels to copy button, fiat menus, fee popover,
and signal status refresh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(crypto): multi-chain deposit addresses and custody sweep

Add per-chain deposit address generation, chain-config registry,
payout webhook handler, and custody sweep job for consolidating
funds across chains.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(crypto): review pass 2 — DRY, hardening, performance, a11y

DRY: Centralize chain display names and Buzz conversion formula in
chain-config.ts. Remove duplicate maps from UI components.

Hardening: Validate chain input with z.enum, add custody sweep
idempotency via Redis dedup, fix auth token race condition with
promise deduplication, sanitize webhook error responses, guard
against NaN balance values.

Performance: Stabilize onRetry and handleFiatChange callbacks to
prevent unnecessary re-renders and API calls. Use ref pattern for
updateSettings dependency.

Accessibility: Wrap currency badges in UnstyledButton with
aria-label and aria-pressed for keyboard and screen reader access.

Tests: Sync test file with multi-chain service API changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(crypto): rename variant, add network labels, fix EVM display name

- Rename DepositCardVariantC → DepositCardContent, remove variants/ dir
- Fix EVM chain display name: "Base" → "Ethereum"
- Show network in chain badge for multi-network chains (e.g., "Ethereum — Base")
- Add NETWORK_DISPLAY_NAMES map and getNetworkDisplayName() to chain-config
- Change "Min" → "Minimum" deposit label for translation compatibility
- Update FiatMenu comment to reference new component name

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(crypto): replace API-based deposit history with local CryptoDeposit table

- Add CryptoDeposit model (replaces CryptoDepositFee) with full deposit
  lifecycle tracking: status, amounts, fees, chain, timestamps
- Migration drops CryptoDepositFee, creates CryptoDeposit with userId index
- processDeposit now upserts CryptoDeposit on every webhook status
- getDepositHistory queries local DB instead of NowPayments API — correct
  chronological ordering across all chains, no more API rate concerns
- Remove bustDepositCache endpoint and payment status Redis caching
- Remove CRYPTO_PAYMENT_STATUS and CRYPTO_DEPOSIT_HISTORY Redis keys
- Add NOWPAYMENTS_IPN_URL env var for configurable webhook URL (dev support)
- Simplify DepositHistory component: chain comes from DB, no currency lookup
- Update tests to match new DB-based architecture (23 tests passing)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(crypto): handle partially_paid, hide fees until finished, ceil rounding

- Handle partially_paid webhook status (grants buzz like finished) — fixes
  $20 order amount causing partial payment instead of finished
- Remove confirmed status handling (confusing UX, no action taken on it)
- Only show fees in deposit history once status is finished
- Ceil fee display to nearest cent instead of rounding

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(crypto): show proper ticker + network in deposit history

Deposit history was showing raw NowPayments currency codes (e.g.,
"USDCBASE") instead of split ticker + network. Re-add currencies
lookup (React Query deduplicates, no extra API calls) to resolve
codes to proper tickers. Shows network badge only when it differs
from the chain name (e.g., "USDC [Base]" but just "BTC" for Bitcoin).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(crypto): show network badge for multi-network tickers, fix fee on partial

- Show network badge only when the ticker exists on multiple networks
  (e.g., "USDC [Base]", "USDT [Tron]") — single-network coins like
  BTC, DOGE, LTC show no badge
- Fix fees not showing for partially_paid deposits (status check was
  only matching 'finished')

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(crypto): use dotted underline abbr-style for fee popover trigger

Replace the info icon button with a dotted-underline text trigger on
the fee amount itself. Clicking the fee text opens the popover with
fee details. Uses cursor-help and decoration-dotted for the standard
abbreviation affordance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(crypto): centralize deposit completed status check

Add isDepositComplete() to chain-config — single source of truth for
statuses that mean buzz was credited (finished, partially_paid). Fixes
toast notification not firing on partially_paid deposits. Replaces all
scattered status === 'finished' checks across service, signal handler,
and deposit history component.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(crypto): clarify deposit address and history are personal

Add "Your" to deposit address label and recent deposits heading so
users understand the address is tied to their account and sharing it
would credit Buzz to them, not the recipient.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(crypto): add bonus buzz display, live timer, normalize deposit status

- Add bonusBuzz (Int) and multiplier (Int, x100) columns to CryptoDeposit
- Store membership multiplier and bonus amount at deposit time
- Show bonus buzz via yellow + hover card with membership percentage
- Normalize partially_paid to finished in DB and signals
- Simplify downstream status checks to === 'finished'
- Add live prop to DaysFromNow for auto-updating relative times
- Use DaysFromNow in deposit history for live-ticking timestamps

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(membership): add collapsible benefits list with buzz multiplier highlight

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(ui): align card styles, fix modal width, polish buzz purchase components

- Switch Coinbase packageSection from Card to Paper with outerCardStyle
  to match Crypto tab card rendering (Card vs Paper have different defaults)
- Add consistent box-shadow to inner cards (customAmountCard, bulkBenefitsCard,
  paymentSection) using the shared light-dark shadow pattern
- Constrain BuzzPurchaseLayout Grid to max-width 1200px so the modal
  doesn't expand unconstrained (size="xxl" isn't a real Mantine preset)
- Simplify BuzzFeatures: remove hover animations, use light variant icons
- Simplify MembershipUpsell: remove collapsible benefits, float image,
  add gradient multiplier text, show all benefits inline
- Fix BonusBuzzContent light mode contrast with Tailwind dark: variants
- Bump currency selector badges from size sm to md, fix multi-network
  badge alignment by wrapping in UnstyledButton
- Update BUZZ_FEATURE_LIST copy (generation types, cosmetic shop)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(dashboard): consolidate buzz dashboard visual consistency

Normalize card borders, gaps, titles, loading states, and chart styles
across the entire buzz dashboard for a cohesive look and feel.

- Add border to .tileCard SCSS class, fix CreatorProgramV2 light-mode bg
- Normalize all grid gutters to md (16px), section spacing with mt-xl
- Standardize card titles to text-xl font-bold (~20px)
- Replace loading spinners with skeleton rows (PurchasedCodesCard) and
  contextual empty state (GenerationBuzzEmptyState)
- Create GenerationBuzzEmptyState with split-panel gradient/spotlight design
- Redesign GeneratedImagesRewards card: info popover, sectioned filter,
  abbreviated Y-axis ticks, thinned date labels, shared tooltip style
- Extract shared chart defaults (scales, tooltips, legends) to chart-defaults.ts
- Use Next.js Link for feature card buttons (client-side navigation)
- Float clear-selection button over scroll area in Top Earning Resources
- Fix Bank Buzz button height, RedeemCodeCard title color, code block light mode
- Fix MembershipUpsell type errors (stale SCSS .d.ts, ReactNode benefits)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(dashboard): remove layout hacks and polish mobile view

- Replace absolute positioning hack with proper CSS Grid for the
  Generation Buzz Earned card layout
- Fix empty state collapsed to a thin line when no data
- Remove arrow icon from transactions "View all" link to prevent
  title wrapping on mobile
- Add whiteSpace: nowrap to View all link

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(ui): remove extra padding from empty deposit state, fix p-in-p nesting

- Remove stacked py="md" from EmptyDepositState inner Stack (outer Paper p="lg" is sufficient)
- Revert h="100%" from deposit card Papers (not needed in Stack layout)
- Fix validateDOMNesting warning in MembershipUpsell BenefitRow (Text renders as div)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:13:34 -10:00