Skip to content

Historical snapshot archived 2026-09-25. This records an earlier review or plan, not current implementation or live ticket state. For current work, follow root AGENTS.md, the relevant BloxClips skill, and owning repository source/tests. Preserve approved decisions as evidence; verify their present authority before acting.

Submission Lifecycle ​

This document traces a submission from creation through review, tracking, and payout.


Phase 1: Submission Creation ​

Where Submissions Are Created ​

Frontend:

  • /dashboard/submit — SubmitVideoScreen.tsx → SubmitVideoModal.tsx
  • /dashboard (overview) — OverviewScreen.tsx embeds SubmitVideoModal
  • /dashboard/campaigns — CampaignsScreen.tsx embeds SubmitVideoModal

Backend Endpoint: POST /api/submissions (src/api/routes/submissions.ts:237)

Who Can Create ​

Authenticated users (clipper) with:

  • Verified email (gate currently disabled in code — commented in submissions.ts:270-284 and SubmitVideoModal.tsx:285-290)
  • At least one LinkedSocialAccount for the platform (or will be prompted to verify)

UI Flow ​

  1. User opens modal → fetches campaigns (GET /api/campaigns)
  2. Selects campaign (filtered: active=true, acceptingSubmissions=true, isDeleted=false)
  3. Pastes video URL(s) — up to 4 per batch
  4. Clicks Submit → runBatch() loops URLs

Backend Processing (per URL) ​

typescript
// src/api/routes/submissions.ts:237-539
1. Validate input (Zod: SubmitVideoSchema)
   - videoUrl: HTTPS, YouTube/TikTok/Instagram domains only
   - campaignId: positive integer

2. Fetch campaign
   - Must exist, active, acceptingSubmissions, not deleted

3. Detect platform (detectPlatform)
   - YouTube, TikTok, Instagram, or unknown → 400

4. Check platform allowed for campaign
   - campaign.allowedPlatforms split by comma

5. Duplicate checks
   a) Same videoLink + same campaignId → 400 "already submitted"
   b) Same videoLink + same user within 10 min → 429 cooldown

6. Scrape video metadata
   - YouTube: extractVideoId → getVideoDetails (batch 50)
   - TikTok/Instagram: Apify actor (scrapeTikTok/scrapeInstagram)
   - Cached 10 min for TT/IG to avoid double-scrape on re-submit after verification

7. Account ownership verification
   - Requires LinkedSocialAccount for (platform, creatorAccountId)
   - If missing → 412 ACCOUNT_VERIFICATION_REQUIRED (frontend shows AccountVerificationModal)
   - If owned by different WebUser → 403

8. Video type validation
   - TT/IG forced to short
   - YouTube: isYouTubeShort (URL-based + duration fallback)
   - Campaign must acceptShorts/acceptsLong accordingly

9. Create Submission (PENDING)
   - userId (discordId or webUserId), webUserId, username
   - videoLink, videoTitle, previewVideoUrl, previewImageUrl
   - initialViews=currentViews=scraped views
   - initialLikes, currentLikes, currentComments
   - platform, status=PENDING, campaignId
   - videoType (short/long), duration, creatorHandle

10. Create initial ViewSnapshot
    - viewCount=scraped views, likes, comments, snapshotDate=now

11. Return 201 with submission

Initial State ​

FieldValueSource
statusPENDINGsubmissions.ts:492
initialViewsScraped view countsubmissions.ts:483
currentViewsSame as initialViewssubmissions.ts:484
initialLikesScraped likessubmissions.ts:485
currentLikesSamesubmissions.ts:489
currentCommentsScraped commentssubmissions.ts:490
acceptedAtnullPrisma default
nextPollAtnullPrisma default
trackingStoppedAtnullPrisma default
consecutiveScrapeFailures0Prisma default
paidOutfalsePrisma default
paidViewsTotal0Prisma default
paidAmountTotal0Prisma default

Phase 2: Admin Review ​

Review Surfaces ​

  1. Global queue: /dashboard/admin/submissions → AdminSubmissionsScreen.tsx

    • Hook: useAdminSubmissions.ts
    • Filters: campaign, status (pending/flagged/all), sort (queue/views/earnings/date)
    • Actions: Accept, Deny (with reason), Flag (with reason), Ban user
  2. Campaign-scoped: /dashboard/admin/campaigns/[id] → AdminCampaignDetailScreen.tsx

    • Hook: useAdminCampaignDetail.ts
    • Same actions, pre-filtered to campaign

Status Transitions (Admin-Initiated) ​

Endpoint: PUT /api/admin/submissions/:id/status (admin.ts:1459)

New StatusCode PathSide Effects
ACCEPTEDadmin.ts:1518-1597acceptedAt, nextPollAt=now, budget clamp, checkAndCloseCampaign, UserNotification, audit log
DENIEDadmin.ts:1528-1532Status flip only
FLAGGEDadmin.ts:1528-1532Status flip only
PENDINGadmin.ts:1528-1532Status flip only (re-open)

Accept Side Effects (Detailed) ​

typescript
// admin.ts:1518-1597
1. Budget pre-check (getCampaignSpend)
   - If campaign at ≥100% budget → 409 BUDGET_FULL

2. Update submission
   - status=ACCEPTED
   - acceptedAt = existing || now
   - nextPollAt = now (immediate poll)

3. Budget clamp on accept (mirrors buttonHandler legacy)
   - getCampaignSpend → remaining budget
   - If currentViews would exceed remaining → frozenViewCount = maxViews
   - campaign.acceptingSubmissions=false, viewsFrozen=true, viewsFrozenAt=now

4. Auto-close campaign
   - checkAndCloseCampaign (95% threshold → acceptingSubmissions=false)

5. User notification
   - UserNotification {type: SUBMISSION_ACCEPTED, campaignId}

6. Audit log
   - AdminAuditLog {action: SUBMISSION_ACCEPTED, previousStatus, reason, campaignId, videoLink}

Deny/Flag Side Effects ​

  • Only status flip + audit log
  • No tracking started
  • No budget impact

Phase 3: Metrics Tracking (Polling) ​

When Tracking Starts ​

Immediately on accept: nextPollAt = now → picked up by next scheduler tick (≤30 min)

Polling Cadence ​

Source: src/utils/tracking/pollScheduler.ts

PeriodInterval
First 48h after acceptedAtEvery 12 hours
After 48hEvery 24 hours

Formula:

typescript
computeNextPollAt(acceptedAt, now):
  ageMs = now - acceptedAt
  interval = ageMs < 48h ? 12h : 24h
  return now + interval

Tracking Stop Conditions ​

  1. Duration expired: acceptedAt + campaign.trackingDurationDays (default 30 days) → trackingStoppedAt, nextPollAt=null
  2. Campaign frozen: campaign.viewsFrozen=true → submissions skipped in tick query
  3. Submission frozen: frozenViewCount != null → skipped in tick query
  4. 3 consecutive scrape failures: FLAGGED, trackingStoppedAt, nextPollAt=null

Tick Processing (runTrackingTick.ts) ​

typescript
runTrackingTick():
  1. Query due submissions (status=ACCEPTED, nextPollAt<=now, trackingStoppedAt=null, frozenViewCount=null, campaign active)
  2. Filter expired (isTrackingExpired)
  3. Group by platform
  4. YouTube: extract IDs → getVideoDetails (batch) → applySuccess
  5. TikTok: scrapeTikTok(batch) → applySuccess
  6. Instagram: scrapeInstagram(batch) → applySuccess
  7. applySuccess:
     - computeScrapeBudgetClamp(submissionId, newViews)
     - If clamp.alreadyFrozen → skip
     - Transaction:
       * Update submission: currentViews, currentLikes, currentComments, lastPolledAt, nextPollAt, consecutiveScrapeFailures=0, frozenViewCount (if clamped)
       * Create ViewSnapshot
     - If clamp.didFreezeCampaign → markCampaignFrozen
  8. applyFailure:
     - consecutiveScrapeFailures++
     - If >=3: status=FLAGGED, trackingStoppedAt, nextPollAt=null
     - Else: nextPollAt = now + 24h

Budget Clamp (First-Come-First-Earned) ​

Source: src/utils/campaignBudget.ts:130-225

typescript
computeScrapeBudgetClamp(submissionId, newViews):
  1. If campaign.viewsFrozen → alreadyFrozen=true
  2. Calculate spend of sibling ACCEPTED submissions (excluding this)
  3. remainingBudget = budgetLimit - spendExcludingThis
  4. maxViewsByBudget = floor(remainingBudget * 1000 / (ratePerK * burn))
  5. cappedNewViews = min(newViews, viewCap)
  6. If cappedNewViews <= maxViewsByBudget → no clamp
  7. Else → frozenViewCount = maxViewsByBudget, didFreezeCampaign=true

Key property: Earlier submissions get paid first; later submissions may be clamped to 0 if budget exhausted.


Phase 4: Payout Eligibility ​

When a Submission Becomes Payout-Eligible ​

  1. status = ACCEPTED
  2. paidOut = false (legacy single-shot exclusion)
  3. Has views > 0 (after caps/thresholds)
  4. Campaign not frozen at 0 views for this submission

Two Payout Flows ​

A. Legacy Admin-Initiated (Single-Shot) ​

  • Submission.paidOut=true, paidAmount, paidAt, payoutId → Payout (legacy statuses: PENDING/PROCESSING/COMPLETED/FAILED)
  • Admin creates payout, includes submissions, sends money
  • Excluded from new user-initiated flow

B. User-Initiated (Delta Flow) — Current ​

  • Clipper clicks "Request Payout" → POST /api/payouts/request
  • Creates Payout (REQUESTED) → async processPayoutRequest()
  • Rescrapes all eligible submissions → builds PayoutItem per submission
  • Delta math: viewsCounted = payableViews - priorPaidViews
  • Admin reviews items → APPROVED/REJECTED/FLAGGED
  • Admin clicks Approve → bumps paidViewsTotal/paidAmountTotal on submissions, sweeps affiliate, tax snapshot → AWAITING_SEND
  • Admin clicks Send (TOTP) → dispatches to rail → COMPLETED

Delta payout detail in payouts-and-dependencies.md


Phase 5: End States ​

End StateHow ReachedTrackingPayout Eligible
PENDINGCreated, never reviewedNoNo
ACCEPTEDAdmin acceptYes (until expired/frozen/flagged)Yes (if views > threshold)
DENIEDAdmin denyNoNo
FLAGGEDAdmin flag OR 3 scrape failuresStoppedNo (tracking stopped)

State Transition Diagram ​

mermaid
stateDiagram-v2
    [*] --> PENDING: POST /api/submissions
    PENDING --> ACCEPTED: PUT /admin/submissions/:id/status {ACCEPTED}
    PENDING --> DENIED: PUT /admin/submissions/:id/status {DENIED}
    PENDING --> FLAGGED: PUT /admin/submissions/:id/status {FLAGGED}
    ACCEPTED --> FLAGGED: 3 scrape failures (auto) OR admin flag
    ACCEPTED --> DENIED: Admin deny (re-review)
    FLAGGED --> PENDING: Admin re-open (status=PENDING)
    DENIED --> PENDING: Admin re-open (status=PENDING)
    ACCEPTED --> [*]: trackingStoppedAt (30 days) OR campaign frozen
    ACCEPTED --> PAID: User payout request → COMPLETED (delta)
    PAID --> PAID: Additional delta payouts (paidViewsTotal increments)

Important Timestamps ​

FieldSet WhenUsed For
createdAtSubmission createSorting, audit
acceptedAtStatus → ACCEPTEDPolling cadence, tracking expiry
lastPolledAtSuccessful scrapeDebugging
nextPollAtAfter each pollScheduler query
trackingStoppedAtExpiry / flag / freezeExclude from ticks
paidAtLegacy payoutLegacy only
lastPaidAtDelta payout item approvedDelta math baseline