# The review queue

> What a free submission is told about its wait, how the estimate is computed, the daily digest that reminds you, and how to fire the hourly job locally.

You are reading one page of the ZeroDirs documentation. ZeroDirs is a paid Astro + Cloudflare Workers starter for directory sites: every page is rendered to static HTML at build time, the site ships no client JavaScript outside `/search/`, and one file — `site.config.ts` — carries roughly 80% of the customisation.

Two things to hold on to before you act on anything below:

- `site.config.ts` is validated by a zod schema with ten cross-field rules. After any edit to it, run `pnpm check:config`; every problem is reported as `site.config.ts › <path>: <message>` and the whole file is checked at once.
- The repository ships its own `AGENTS.md` with twenty hard rules, and a machine-checked test suite behind them. If you are working inside a ZeroDirs project, read that file first — it overrides anything general you infer from this page.

Source: https://zerodirs.com/docs/submissions/queue/

---

A free submission waits for a person. The queue tells the submitter how long that is likely to
take, offers the paid plans as a way past it, and mails you once a day while anything is
waiting. It is part of `features.submissions`; the `queue` block in `site.config.ts` tunes it.

> **Free edition**
>
> The free edition has no Worker and no D1, so it has no submissions, no queue and no hourly job.

## Setup

1. Set the two fields. Both have defaults, so the block may stay omitted.

   ```ts title="site.config.ts"
   queue: { estimateDays: 21, digestHourUtc: 9 },
   ```

   `estimateDays` is what a site with no approval history tells submitters. `digestHourUtc` is
   the UTC hour of your reminder, or `null` to switch it off.

   ```sh title="Terminal"
   pnpm check:config
   ```

   `site.config.ts OK — <your site name> (https://your-domain.com)`.

2. Choose where the digest goes.

   ```ts title="site.config.ts"
   admin: { email: 'you@example.com' },
   ```

   Without `admin.email` it goes to `site.contactEmail`. `providers.adminNotify` picks the
   channel — the same one the per-submission notification uses — and `'off'` silences both.

3. Deploy.

   ```sh title="Terminal"
   pnpm deploy
   ```

   The hourly trigger `0 * * * *` ships in `wrangler.jsonc`; keep it, because a deploy without
   it clears the schedule.

## Verify

Submit a free listing locally (`pnpm dev`, then `/submit/`) and read what it is told.

| What to check | Where | You should see |
| --- | --- | --- |
| The estimate | the status page after "Submit for review" | "Expected to go live in about 3 weeks — number 1 in the queue." on a fresh local database |
| The receipt | the `submitted` row in `email_outbox` | a `queue.wait` of the same phrase, and `queue.upgrades` when paid plans are on |
| The tiles | `/admin/` | "In the free queue" counts it; "Wait submitters are told" shows the phrase with its basis underneath |
| The digest | fire the hourly job at the digest hour (below) | `adminDigest=1` in the log line and an `admin-digest` row in `email_outbox` |

## How it works

**The estimate is throughput, not a promise.** The free queue is every row that is `pending`,
unpaid-by-design (`payment_status` `n/a`) and on the free tier — the first tier whose price is
zero. A submission's position is the number of free submissions created before it, plus one. The
rate is the approvals of the last 30 days divided by 30, counting only rows with
`source = 'submit'`: seeded, imported and admin-created listings never count as evidence that the
queue moves. Days to expect is the position divided by the rate, rounded up, at least one. With
no approvals in the window there is no rate, and `queue.estimateDays` is used instead.

The number becomes a phrase: up to a day, "within a day or two"; under a week, "in about N
days"; under eight weeks, "in about N weeks"; beyond that, "in about N months". It appears in
three places. The status page says "Expected to go live … — number N in the queue." while the
submission is pending and free. The receipt email says "Expected to go live …" for the free
tier. The dashboard tile "Wait submitters are told" shows it with its basis: "from N approvals
in 30 days", or "queue.estimateDays, until approvals set a rate".

**The upgrade.** While a free submission is pending, its status page lists the enabled priced
tiers under "Skip the queue", each with its price and review promise and an "Upgrade to …"
button that opens Stripe Checkout with `?tier=` on the checkout link. The listing changes tier
only when the payment lands, so an abandoned checkout changes nothing. The receipt carries the
same offer as one sentence — "Skip the queue: Express is reviewed within 48 hours, … — upgrade
from your status page." Both are absent when `features.payments` is off, the payment provider
is `none`, or no enabled tier has a price.

**The daily digest** runs inside the hourly job. At the tick whose UTC hour equals
`queue.digestHourUtc` it queues one `admin-digest` mail — unless one was queued in the last
twenty hours (a tick that runs twice sends once), the queue holds nothing approvable (pending and
free, or pending and paid), or `providers.adminNotify` is `'off'`. It says how many are waiting
and how long the oldest has waited, what free-tier submitters are currently told and from how
many approvals, and which paid submissions are within a day of the review promise in their
tier's `slaHours`. The button goes to `/admin/`. It leaves through the outbox on the
admin-notify channel, flushed by the same tick.

## Firing the hourly job locally

Nothing runs the cron on a clock in local development; wrangler says so when it starts. The
built Worker, run under `wrangler dev`, answers the trigger endpoint by hand:

```sh title="Terminal"
pnpm build
pnpm exec wrangler dev
```

```sh title="Terminal"
curl "http://localhost:8787/cdn-cgi/local/scheduled?cron=0+*+*+*+*"
```

The terminal running `wrangler dev` logs one line:

```text
jobs: expireFeatured=0 flushOutbox=0 retryRebuilds=0 purgeUnpaid=0 adminDigest=0 (cron=0 * * * *, in <n>ms)
```

Add `&time=<milliseconds since the epoch>` to run the tick as if at that moment — pick one
inside the digest hour, with a free submission pending, and the line ends with `adminDigest=1`.

> **Not under astro dev**
>
> `astro dev` answers this path with the site's 404 page. Build first and use the built Worker.

## When it is off or degraded

| Configuration | What a submitter sees | What `/admin/` shows | Fix |
| --- | --- | --- | --- |
| `queue.digestHourUtc: null` | the estimate as before | the tiles as before; no digest arrives | set an hour |
| `providers.adminNotify: 'off'` | the estimate as before | no digest and no per-submission notification; nothing reported | set it to `'same'` |
| no approvals in the last 30 days | "in about 3 weeks" on the defaults | the tile reads "queue.estimateDays, until approvals set a rate" | approve real submissions, or lower `estimateDays` |
| `providers.email: 'console'` | — | the digest is logged, not sent; warning: `providers.email is "console": submitters get no mail, it is only logged.` | [Email](https://zerodirs.com/docs/submissions/email/) |
| `features.payments: false`, or no priced tier enabled | no "Skip the queue" section; no upgrade sentence in the receipt | — | [Plans and payments](https://zerodirs.com/docs/submissions/payments/) |
| the cron trigger removed from `wrangler.jsonc` | the estimate as before | no digest; queued mail waits until something else flushes it | restore `triggers.crons` and redeploy |

## Configuration

| Field | Default | What it changes |
| --- | --- | --- |
| `queue.estimateDays` | `21` | the wait stated until real approvals set a rate; 1 to 365 |
| `queue.digestHourUtc` | `9` | the UTC hour of the digest, 0 to 23, or `null` for none |
| `admin.email` | unset | the digest's recipient; falls back to `site.contactEmail` |
| `providers.adminNotify` | `'same'` | the channel the digest travels on; `'off'` disables it |
| `tiers[].slaHours` | unset | the review promise quoted in the upgrade offer and watched by the digest |

Reference: [queue](https://zerodirs.com/docs/configure/reference/#queue), [admin](https://zerodirs.com/docs/configure/reference/#admin),
[providers](https://zerodirs.com/docs/configure/reference/#providers), [tiers](https://zerodirs.com/docs/configure/reference/#tiers).

## Files

- site.config.ts the `queue` block, `admin.email`, `providers.adminNotify`
- wrangler.jsonc `triggers.crons`
- src/
  - worker.ts `scheduled()`, the cron entry
  - server/
    - queue.ts the estimate, the wording, the upgrade sentence, the one-query snapshot
    - jobs.ts `runJobs()` and `sendAdminDigest()`
    - queries.ts the dashboard's queue numbers
    - email/templates/
      - admin-digest.ts the reminder
      - submitted.ts the receipt with the wait
  - pages/submit/status/
    - [token].astro computes the estimate and the upgrade links
  - templates/submit/StatusPage.astro "Skip the queue"
  - templates/admin/DashboardPage.astro the tiles
- tests/workers/jobs.test.ts the digest, the repeat guard, the snapshot

## Related

- [The submission form](https://zerodirs.com/docs/submissions/form/) — Where a free submission comes from, and the status link that shows its wait.
- [Plans and payments](https://zerodirs.com/docs/submissions/payments/) — What happens when a submitter takes the upgrade.
- [Admin](https://zerodirs.com/docs/submissions/admin/) — The dashboard the digest points at, and approving from it.
- [Email](https://zerodirs.com/docs/submissions/email/) — The outbox and the provider the digest travels through.
