Appearance
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.tsxembedsSubmitVideoModal/dashboard/campaigns—CampaignsScreen.tsxembedsSubmitVideoModal
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-284andSubmitVideoModal.tsx:285-290) - At least one
LinkedSocialAccountfor the platform (or will be prompted to verify)
UI Flow
- User opens modal → fetches campaigns (
GET /api/campaigns) - Selects campaign (filtered:
active=true,acceptingSubmissions=true,isDeleted=false) - Pastes video URL(s) — up to 4 per batch
- 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 submissionInitial State
| Field | Value | Source |
|---|---|---|
status | PENDING | submissions.ts:492 |
initialViews | Scraped view count | submissions.ts:483 |
currentViews | Same as initialViews | submissions.ts:484 |
initialLikes | Scraped likes | submissions.ts:485 |
currentLikes | Same | submissions.ts:489 |
currentComments | Scraped comments | submissions.ts:490 |
acceptedAt | null | Prisma default |
nextPollAt | null | Prisma default |
trackingStoppedAt | null | Prisma default |
consecutiveScrapeFailures | 0 | Prisma default |
paidOut | false | Prisma default |
paidViewsTotal | 0 | Prisma default |
paidAmountTotal | 0 | Prisma default |
Phase 2: Admin Review
Review Surfaces
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
- Hook:
Campaign-scoped:
/dashboard/admin/campaigns/[id]→AdminCampaignDetailScreen.tsx- Hook:
useAdminCampaignDetail.ts - Same actions, pre-filtered to campaign
- Hook:
Status Transitions (Admin-Initiated)
Endpoint: PUT /api/admin/submissions/:id/status (admin.ts:1459)
| New Status | Code Path | Side Effects |
|---|---|---|
ACCEPTED | admin.ts:1518-1597 | acceptedAt, nextPollAt=now, budget clamp, checkAndCloseCampaign, UserNotification, audit log |
DENIED | admin.ts:1528-1532 | Status flip only |
FLAGGED | admin.ts:1528-1532 | Status flip only |
PENDING | admin.ts:1528-1532 | Status 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
| Period | Interval |
|---|---|
First 48h after acceptedAt | Every 12 hours |
| After 48h | Every 24 hours |
Formula:
typescript
computeNextPollAt(acceptedAt, now):
ageMs = now - acceptedAt
interval = ageMs < 48h ? 12h : 24h
return now + intervalTracking Stop Conditions
- Duration expired:
acceptedAt + campaign.trackingDurationDays(default 30 days) →trackingStoppedAt,nextPollAt=null - Campaign frozen:
campaign.viewsFrozen=true→ submissions skipped in tick query - Submission frozen:
frozenViewCount != null→ skipped in tick query - 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 + 24hBudget 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=trueKey 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
status = ACCEPTEDpaidOut = false(legacy single-shot exclusion)- Has views > 0 (after caps/thresholds)
- 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) → asyncprocessPayoutRequest() - Rescrapes all eligible submissions → builds
PayoutItemper submission - Delta math:
viewsCounted = payableViews - priorPaidViews - Admin reviews items →
APPROVED/REJECTED/FLAGGED - Admin clicks Approve → bumps
paidViewsTotal/paidAmountTotalon 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 State | How Reached | Tracking | Payout Eligible |
|---|---|---|---|
PENDING | Created, never reviewed | No | No |
ACCEPTED | Admin accept | Yes (until expired/frozen/flagged) | Yes (if views > threshold) |
DENIED | Admin deny | No | No |
FLAGGED | Admin flag OR 3 scrape failures | Stopped | No (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
| Field | Set When | Used For |
|---|---|---|
createdAt | Submission create | Sorting, audit |
acceptedAt | Status → ACCEPTED | Polling cadence, tracking expiry |
lastPolledAt | Successful scrape | Debugging |
nextPollAt | After each poll | Scheduler query |
trackingStoppedAt | Expiry / flag / freeze | Exclude from ticks |
paidAt | Legacy payout | Legacy only |
lastPaidAt | Delta payout item approved | Delta math baseline |