Submissions
The 5-status moderation workflow — from "user hits Submit" to "product goes live".
Submissions are the bridge between users and published products. Every product starts as a submission and passes through your moderation queue.
The 5 statuses
Defined in the submissionStatus enum (lib/db/schema.ts):
draft ─► pending ─► revision ─► accepted ─► (published when batch publishes)
│ │
└────► rejected ◄───┘| Status | Meaning |
|---|---|
draft | User saved partial data but didn't submit. |
pending | Awaiting admin review. Shows in /admin/submissions. |
revision | Admin requested changes. User sees revision reasons on /profile and gets the revision email. |
accepted | Admin approved. Scheduled for a batch. |
rejected | Admin declined. User gets the rejection email. No automatic Stripe refund — see below. |
Submitting (user side)
/submit is the form. Required fields:
- Name, tagline, website URL, thumbnail
- Tier:
free/boost/highlight
Optional: logo, description (Tiptap WYSIWYG, saved as HTML).
Tier behavior on submit:
- Free → submission created with status
pending - Boost → redirected to Stripe Checkout. On
checkout.session.completedwebhook, the submission is auto-accepted and auto-scheduled to the next open week. Payment confirmation email sent. - Highlight → redirected to Stripe Checkout. On
checkout.session.completed, the product is instantly published to the current batch withhighlight_expires_atset tonow() + highlight_duration_days. Payment confirmation email sent.
See app/api/submissions/route.ts + the Stripe webhook at app/api/stripe/webhook/route.ts.
Reviewing (admin side)
At /admin/submissions, each row exposes three actions:
- Accept — opens a modal to pick the target week/year for scheduling
- Reject — opens a modal for the rejection reason
- Request revision — opens a modal with checkbox-based revision reasons (plus a free-text field):
Thumbnail quality,Name/tagline,Website URL,Description
The user receives the appropriate email (submission-accepted.tsx, submission-rejected.tsx, or submission-revision.tsx). The revision email includes a deadline (Sunday of the current week).
Note — no bulk actions. The admin UI does not currently support multi-select, keyboard shortcuts, or bulk accept/reject. Each action is performed per-row.
Refund policy on reject
lib/stripe-refund.ts does not issue refunds automatically. It generates a link to the Stripe Dashboard payment page, which the admin opens to refund manually.
If you want automated refunds on rejection, replace the dashboard-link logic with a direct call:
// lib/stripe-refund.ts
import { getStripe } from "./stripe"
export async function refundPayment(paymentIntentId: string) {
return getStripe().refunds.create({ payment_intent: paymentIntentId })
}Then call it from the reject action.
Scheduling to a specific batch
By default, the admin accept modal picks the next open batch. You can override scheduledWeek / scheduledYear to any future ISO week/year. The weekly cron only picks submissions where these match.
Email notifications
Every status change that affects the user fires a Plunk email (if PLUNK_SECRET_KEY is set):
| Event | Template file |
|---|---|
| User submits (free) | submission-received.tsx |
| Stripe payment completes (Boost/Highlight) | payment-confirmed.tsx |
| Admin accepts (free tier only — paid tiers are auto-accepted at payment) | submission-accepted.tsx |
| Admin requests revision | submission-revision.tsx |
| Admin rejects | submission-rejected.tsx |
| Batch publishes the user's product | submission-published.tsx |
Templates live in emails/.
Rate limiting
The submit API (app/api/submissions/route.ts) is rate-limited per user using the submissions_per_day setting (default in lib/settings-shared.ts).
Abuse protection
- Cloudflare Turnstile on sign-up (not directly on submit, but bots need an account first)
- Rate limit via
submissions_per_day - Unique slug — collisions are resolved by appending a random suffix