Discord application/form management bot with a web dashboard. Two processes share one Postgres database and one schema definition:
bot/— Discordeno v20 (Deno) gateway bot. Owns the live Discord connection: publishing panels, showing modals, posting review embeds, managing roles, sending DMs, running scheduled poll publish/close.api/— Express (Node) REST API. Stateless, horizontally scalable. Serves the dashboard: OAuth2 login, form/panel/poll CRUD, submission history, DM template editor, manager delegation.shared/— Drizzle ORM schema and TypeScript types imported by both. This is the single source of truth for the data model and wire format; never duplicate a type or table definition inbot/orapi/.
Stack: TypeScript · Deno · Node · React · PostgreSQL · Redis · Drizzle ORM · Docker · Terraform · Google Cloud Platform (Cloud Run, Cloud SQL, Memorystore, Secret Manager, Artifact Registry) · GitHub Actions
Runs on Google Cloud Platform — Cloud Run for the API and dashboard,
Cloud SQL for Postgres, Memorystore for Redis, Secret Manager for
credentials, and Artifact Registry for images. Infrastructure is defined in
Terraform (terraform/) and deploys through GitHub Actions using Workload
Identity Federation, so no service account keys are stored anywhere.
It also runs anywhere Docker does — a VPS, a Raspberry Pi, or a laptop. See
PI.md and SETUP.md.
| Guide | For |
|---|---|
POC.md |
Two containers, no Redis, no cloud account — start here |
SETUP.md |
Full local setup and the manual steps |
terraform/README.md |
GCP infrastructure as code, with costs |
DOCKER.md |
Container and Cloud Run specifics |
PI.md |
Raspberry Pi |
SELF_HOSTING.md |
Running it for your own server |
git clone https://github.com/creeperdiamonds/appealy.git
cd appealy
./setup.shThe script checks your tools, generates the secrets you shouldn't be typing by
hand, walks you through the Discord portal, starts the containers, runs the
migration, and registers slash commands. It's safe to re-run — it never
overwrites an existing .env and skips the migration if your database already
has tables.
./setup.sh --check verifies your tools and changes nothing.
Running on a Raspberry Pi? See PI.md — setup.sh detects it and tunes
itself, but there are two things to get right before you start.
Then read SETUP.md for what's deliberately left manual.
POC.md gets you running on two containers with no Redis and no cloud
account. Worth doing before touching GCP — deploying to the cloud first means
debugging the application and the infrastructure at the same time, without
knowing which one is broken.
cd terraform
cp terraform.tfvars.example terraform.tfvars # set project_id
terraform init && terraform plan # plan lists what gets billed
terraform apply
terraform output github_variables # paste into repo settingsThen run the Deploy to GCP Cloud Run workflow from the Actions tab,
picking api or web.
Note on the bot specifically: a Discord gateway connection is a WebSocket that
must stay open continuously, which is the opposite of what Cloud Run is built
for. It needs --min-instances=1 --no-cpu-throttling --max-instances=1 to
survive, and those flags bill CPU continuously for roughly the price of an
e2-micro VM. DOCKER.md covers why a small Compute Engine instance is the
better home for it.
Only the bot process holds a live Discord gateway/REST session. Some
dashboard actions (publish a panel message, force-publish a poll, list a
guild's channels/roles for a picker) require that session. Rather than give
the API its own bot token and duplicate Discord REST logic, the API calls a
small internal control server the bot exposes on a private, unpublished
port (bot/src/core/controlServer.ts, called from api/src/services/botBridge.ts
and api/src/routes/guildResources.ts), authenticated with a shared secret.
In docker-compose.yml this port is reachable only on the internal Docker
network — it is not the public API and must never be exposed to the internet.
Discord Modals only support text input components; there is no native select
menu inside a modal. Forms with a select-type question therefore show an
ephemeral select-menu message first (bot/src/interactions/selects/formSelectStep.ts),
stash the choice in Redis keyed by (userId, formId) with a 15-minute TTL
(bot/src/services/pendingAnswers.ts), and open the modal for any remaining
text questions once all selects are answered. The modal submit handler merges
both answer sets before persisting.
This project is an original implementation inspired by the publicly documented feature set of comparable Discord application bots (panels, staff review, role automation, gating, polls) — no third-party source or assets were used. A few design decisions worth knowing:
/apply <application_name>and panel buttons both route throughrunApplicationFlow(bot/src/interactions/buttons/panelOpen.ts) so gating and modal behavior can't drift between the two entry points.- Ping roles: forms can configure roles to be pinged in the review
channel when a new submission arrives (
forms.pingRoleIds). - Denial-path roles are split into
denyRemoveRoleIds(stripped on denial) anddeniedGrantRoleIds(added on denial, e.g. to tag a previously-denied user) — distinct from the accept-path grant/remove pair. - Action on leave:
forms.leaveActionauto-denies a pending submission if the applicant leaves the guild before review (bot/src/events/guildMemberRemove.ts), so the queue doesn't accumulate stale entries for people who are gone. /export_applicationsstreams matching submissions to CSV as a Discord file attachment./pollcreates and immediately publishes a poll (channel, question, up to 9 options, optional multiselect) as a fast path alongside the dashboard's schedule-for-later flow inbot/src/services/pollService.ts.- Panel display type: panels can show attached forms as buttons (capped at 5 per panel) or a dropdown select (up to 25) — both available to every guild regardless of billing tier; see "Pricing model" below for what's actually metered.
- Manager delegation (fine-grained review/manage access without full Discord Administrator) is likewise available to every guild — not a paid feature.
The bot's source is open — anyone can self-host it for free, no license key, no feature gate. Two independent, additive billing axes exist on top of that for people who'd rather not run their own infrastructure or who outgrow the free throughput caps. Neither axis unlocks features; every feature described in this README is available at every tier.
Billing is annual-only — there is no monthly option, anywhere, by design. Standard card-processing fees run roughly 2.9% + $0.30 per transaction. At the price points here (a few dollars up to ~$15/mo equivalent), that flat $0.30-per-charge component would eat a large, disproportionate share of revenue if billed monthly — twelve small monthly charges pay that $0.30-plus-percentage fee twelve separate times for the same total revenue one annual charge would collect once. Concretely: a hypothetical $5/mo tier billed monthly loses roughly 9% of revenue to fees across a year; the same tier billed as one $60/year charge loses roughly 3%. It gets worse at smaller amounts — a hypothetical $0.83/mo charge (the monthly-equivalent framing of the $10/year hosting fee) would lose over a third of its value to the flat fee alone if it were ever actually billed that way, which is exactly why it isn't. So every paid option collects one annual charge, and the dashboard always shows both the annual price and a computed (never hardcoded) monthly-equivalent figure purely for comparison, never as an actual billing cadence.
Axis A — Throughput. Every guild gets a free daily/standing cap on
submissions, tickets, giveaway entries, dashboard API requests, forms per
guild, panels per guild, roles per rule-type, and history retention. Two
preset tiers raise those caps at a fixed annual price; a custom option
lets a guild admin set its own numbers directly on the dashboard, priced
live with a simple per-unit formula so the price is visible before ever
reaching checkout — never a "contact us" step. Selections that total less
than MINIMUM_CHARGE_CENTS ($5) are refused at checkout rather than
billed — a charge that small isn't worth firing on its own; a production
implementation should roll it into the next renewal instead of skipping it
silently. Every cap, including custom, has a hard ceiling
(CUSTOM_CAP_MAXIMUMS in shared/schema/pricing.ts) — there is no
unlimited tier anywhere in this system, by design, so worst-case cost is
always bounded.
Axis B — Hosting. Self-hosting (cloning this repo and running it
yourself) is always free. Paying for custom hosting means we run a
dedicated instance of this same bot for you under your own bot token —
priced near cost, cheaper than provisioning it yourself on a generic
hosting provider, and completely unrelated to Axis A: a guild can be on
free throughput with custom hosting, or a paid throughput tier on shared
hosting, in any combination. $10/year — see
CUSTOM_BOT_HOSTING_USD_CENTS_PER_YEAR in shared/schema/pricing.ts.
The two axes are strictly additive: no bundle or multi-axis discount exists, and none is planned — each axis is priced and charged on its own.
All money in this codebase is integer cents, never floats — floating- point cents arithmetic accumulates rounding error across many transactions in ways that are individually invisible and collectively a real bug.
All pricing logic lives in shared/schema/pricing.ts as a pure function
with no side effects, so the dashboard (for live pre-checkout quoting),
the API (api/src/routes/billing.ts), and the bot's enforcement
(bot/src/services/rateLimitService.ts) can never disagree about a price
or a cap.
Tebex is the merchant of record: it sells to the customer, collects the money, and owns sales-tax and VAT registration and remittance in the jurisdictions it sells into. Taking cards directly would mean being the merchant — which starts with handing a processor a taxpayer identification number (an SSN or ITIN for an individual, an EIN for a company) and continues with owning tax registration wherever customers are.
POST /billing/checkout creates a Tebex checkout for a computed, non-catalog
annual amount. The Checkout API takes items with an inline package carrying
a name and a price chosen at request time, which is what makes it workable
for the custom-caps tier. Earlier revisions of this document claimed the
opposite — that Tebex could not price anything without pre-created SKUs — and
that is no longer true; nothing about the pricing model had to change to move,
because shared/schema/pricing.ts still computes the number and the service
still just hands it over. The chosen plan travels in the basket's custom
object.
Items are created as annual subscriptions, so Tebex emits the recurring lifecycle events. That is how a plan now ends when a customer stops paying — the previous integration handled only the first payment, so a plan bought once never lapsed.
The guild's plan is changed in exactly one place:
api/src/services/billingService.ts::applyPlanChange, called only from
api/src/routes/tebexWebhook.ts after Tebex confirms payment succeeded.
That webhook handler does three things worth knowing if you're auditing
or extending it:
- Verifies the signature against the raw request body (mounted
before
express.json()for exactly this reason — a re-serialized body won't match the signature computed over the original bytes — Tebex's own docs call out Express by name for this). - Re-derives the plan from the session's server-set metadata, never from anything a client sends to the webhook directly.
- Recalculates what the plan should cost and compares it to what Tebex says was actually paid before applying anything — currency included, since 60 of the wrong unit is not the price. This is Tebex's own webhook documentation's warning: a signature proves the message came from the payment provider, not that its contents match what you intended to charge for. A mismatch is logged and the plan change is refused rather than applied at the wrong price.
- Ends plans that end.
recurring-payment.endedandpayment.refundedreturn the guild to the free selection. The subscription is matched back to its guild throughguilds.tebex_recurring_reference, recorded when the first payment was applied, because the lifecycle events identify themselves by that reference and do not carry the basket's custom data.
Downgrading to the fully-free selection is the one plan change that
bypasses checkout entirely (PUT /billing/downgrade-to-free) since it
never involves a payment to confirm.
Same original-implementation approach as the applications/panels/polls core — independently built, informed by publicly documented behavior in this category of bot, no third-party source or assets used.
- Tickets (
ticketConfigs/ticketstables,bot/src/services/ticketService.ts): staff choose per ticket-type whether opened tickets become a private channel, a private thread, or a public thread (ticketConfigs.channelType) — your requested per-config choice rather than a single hardcoded mode. Includes claim/close buttons, an optional transcript posted to a configured channel on close, and the sameleaveActionpattern used for applications (none/close/notify) when the ticket opener leaves the guild. - Giveaways (
giveaways/giveawayEntries,bot/src/services/giveawayService.ts): button-based entry (not reactions), role gating identical in shape to form gating, optional bonus-entry weighting per role, scheduled auto-end via the existing scheduler, and reroll support that excludes prior winners. - Verification (
verificationConfigs,bot/src/interactions/buttons/verify.ts): either a single-button instant grant or a retype-the-code text challenge (method: "captcha") — a real image CAPTCHA would need an external rendering service, so this uses a short random alphanumeric code shown in the ephemeral response and confirmed via modal, which still screens out naive auto-join bots. An optionalunverifiedRoleIdgate role can be applied on join and stripped on verify, with an optional auto-kick timer for members who never complete it. - Welcomer (
welcomerConfigs,bot/src/events/guildMemberAdd.ts): join channel message, optional join DM, optional auto-role grant, and an optional leave-channel message — one config row per guild since these are almost always configured together.
All four follow the same conventions as the core system: Drizzle schema in
shared/schema/schema.ts, DTOs in shared/types/index.ts, dashboard CRUD
under api/src/routes/, and any action requiring the live gateway session
(publishing a panel, ending a giveaway) proxied through the bot's internal
control server exactly like the existing panel/poll publish flow.
cp .env.example .env
# fill in Discord app credentials, generate TOKEN_ENCRYPTION_KEY and
# INTERNAL_RPC_SECRET with: openssl rand -hex 32
docker compose up -d postgres redis
cd api && npm install && npm run db:generate && npm run db:migrate
cd ..
docker compose up --buildRegister a Discord application at https://discord.com/developers/applications,
enable the Server Members Intent and Message Content Intent under
Bot settings, and set the OAuth2 redirect URI to match DISCORD_REDIRECT_URI.
One container serves all of it, split by path in web/nginx.conf:
| URL | Is |
|---|---|
http://localhost:5173/ |
The marketing site (site/) |
http://localhost:5173/dashboard/ |
The console — this is where you log in |
http://localhost:5173/status |
A redirect to the public status page, which is a Cloudflare Worker on this repository's status-page branch rather than part of this container |
http://localhost:5173/auth/…, /api/… |
Proxied through to the API |
Port 3001 is still published for the API so you can curl it or replay a
webhook against it directly. The browser should never use it.
DISCORD_REDIRECT_URI defaults to
http://localhost:5173/auth/discord/callback — port 5173, the console,
not 3001. The same rule holds in production: it is the console's URL, never
the API service's.
This is not a preference. The session cookie is SameSite=Lax, which means
the browser attaches it only to same-site requests, and "site" is the
registrable domain rather than the origin. Serve the console on one hostname
and call the API on another and the cookie is simply never sent: login looks
like it worked and every request after it is anonymous. CORS cannot fix that —
CORS decides whether a response may be read, SameSite decides whether the
cookie is attached at all.
So nginx proxies /auth/ and /api/ from the console's own origin, and
VITE_API_URL is empty — the bundle calls those paths relative. Setting
it to an absolute URL puts the two back on separate origins and reintroduces
the bug. The full reasoning, including why Cloud Run's default *.run.app
hostnames make this worse rather than better, is at the top of
web/nginx.conf.
Local development hides all of this: localhost:5173 and localhost:3001
differ only by port, and ports are not part of a site, so the cookie flows and
everything works right up until it is deployed.
/auth/discord/login?mode=popup runs the OAuth round trip in a small window
and posts the result back to the opener, so the console keeps its state
instead of being torn down and rebuilt around a redirect. If the popup is
blocked — no user activation, or Discord's in-app browser, which does not give
a popup a window.opener — the client falls back to a full-page redirect
automatically. Nothing sensitive crosses postMessage; the session is the
httpOnly cookie, and the message carries only whether it worked.
Keeping window.opener alive across the trip to discord.com needs
Cross-Origin-Opener-Policy: same-origin-allow-popups, which web/nginx.conf
sets on the console's shell. A stricter same-origin there silently breaks the
popup and leaves the redirect fallback as the only working path.
shared/
schema/schema.ts # Drizzle table definitions (source of truth)
schema/gating.ts # pure role/cooldown/limit gating engine
types/index.ts # DTOs, custom_id encode/decode, template interpolation
bot/src/
core/ # client bootstrap, env, scheduler, internal control server
events/ # gateway event handlers, central interactionCreate router
interactions/
buttons/ # panel-open, review-accept, review-deny
modals/ # form submit, deny-reason submit
selects/ # select-question pre-step, poll vote
commands/ # /panel, /forms, /dashboard slash commands
services/ # DM templating, permission/hierarchy checks, poll rendering
db/ # Drizzle client
api/src/
routes/ # auth, forms, panels, submissions, dm-templates, polls,
# staff-permissions, guild resource proxy
middleware/ # session auth (with token refresh), guild access control
services/ # Discord OAuth2 client, bot-bridge RPC client
db/ # Drizzle client
Questions can optionally require an answer to match a regex pattern, in
addition to (or instead of) plain min/max length — e.g. "must be a valid
Minecraft username" as ^[A-Za-z0-9_]{3,16}$. Checked at both submission
points: the in-server modal submit (formSubmit.ts) and each individual
answer in the DM application flow (dmApplicationService.ts), via the
same shared validateAnswersAgainstQuestions/validateAnswerAgainstPattern
logic so the two flows can't enforce different rules. Not applicable to
select-type questions (enforced by a zod .refine() in
api/src/routes/forms.ts, not just a UI suggestion).
Read shared/schema/regexValidation.ts before relying on this in
production — it states its own real limitation rather than hiding it.
In short: patterns are vetted against a conservative, heuristic rejection
list of known catastrophic-backtracking shapes (nested quantified groups,
quantified alternation, backreferences, lookaheads/lookbehinds) before
they're ever stored, capped at 256 characters, and answers are capped at
4000 characters before a pattern is ever run against them. What this
module does not have is a runtime execution timeout — JavaScript's
RegExp.test() is synchronous and cannot be interrupted by a
setTimeout race once it starts, so a genuine hard timeout would require
running matches in a separate, forcibly-terminable worker thread, which
isn't built here. That means the static rejection list is the only
real defense, not one layer of several — if a pattern shape slips past
the heuristics, it can still hang the process. The heuristics are
deliberately conservative (they reject some safe patterns, never accept
all unsafe ones by design), but "conservative heuristic" is not the same
guarantee as "provably safe," and that gap is worth knowing about rather
than discovering in production. shared/schema/__tests__/regexValidation.test.ts
covers the accept/reject cases as they currently stand — run it after
any change to the heuristics.
-
/export(bot slash command) andGET /api/guilds/:guildId/export(dashboard) both produce a complete JSON dump of everything Appealy stores for a guild — forms, questions, panels, submissions/answers, DM templates, ticket configs/tickets, giveaways/entries, verification config, welcomer config, role menus, anti-raid config, quick responses, sticky messages, and staff delegation. Billing/session data is deliberately excluded — it's an Appealy-hosting-account concern, not portable server configuration. The intent: a server that outgrows the shared bot and wants a dedicated self-hosted instance can take every bit of their own data with them, no lock-in. -
Owner-only, not admin-only — both the slash command and the API route check the guild's actual owner (
permissionService.ts::isGuildOwneron the bot side,guildAccess.ts::requireOwnerAccesson the API side), deliberately stricter than the Administrator/manager-delegation levels used everywhere else. The reasoning: some owners want the assurance that a full data dump — or a bulk import of historical applicant data — can't be triggered by any admin they've delegated day-to-day management to, only by themselves. -
/import-appealy(bot slash command, file upload) andPOST /api/guilds/:guildId/migrate/import(dashboard) take an export from one server and stand the same configuration up in another. Owner-only, and for a sharper reason than the export is: exporting reads your own server, importing writes to this one and inreplacemode deletes what is already there.The hard part is not the data, it is that an export is not portable — every channel and role id in it names something in the source server. So the importer re-keys every row and rewrites the references between them, then resolves each snowflake through an optional source→target map. What it cannot resolve it clears, or points at a required fallback channel, and every one of those comes back in a report of what to reconnect.
One rule is worth stating on its own: a form that loses its role gating is imported switched off. A staff application whose gate referenced roles that mean nothing here would otherwise import not as broken but as open — and an open staff application is a worse outcome than a missing one. The same reasoning skips a role menu whose options all failed to resolve rather than creating an empty one.
Submissions, answers, tickets, ban appeals, giveaway entries and gate overrides are never imported. They are not configuration; they are what people in the source server wrote, much of it about themselves. Staff delegations are excluded too, on the grounds that an import should never be a way to hand someone review access.
-
/import-appy(bot slash command, file upload) andPOST /api/guilds/:guildId/migrate/appy-submissions(dashboard, JSON body) import a submissions-history export from Appy — a different, closed-source Discord application bot — into an existing Appealy form. Built against a real sample export (confirmed shape documented inshared/services/appyImport.ts). Appy's export has no question-definition metadata (type, required, validation) and no reviewer/reason data — only submission text and outcome — so the importer matches each historical question to the target form's real questions by exact text match and leaves reviewer/reason fields empty rather than inventing them. Unmatched questions are surfaced in the response rather than silently dropped or guessed at.It is idempotent, and that is load-bearing. Every imported row stores Appy's own submission id in
submissions.import_source_id, behind a partial unique index on(form_id, import_source_id). Re-uploading the same file imports nothing the second time and reports the rows as already imported.Before that column existed the importer had no way to recognise its own work, and the failure message told people to re-run — so the documented recovery from a half-finished import silently duplicated every row that had already succeeded, attributed to real applicants, with no way to tell the copies apart. Re-running is now the correct recovery, not the destructive one.
Bounded per guild, not per call. Imports are exempt from
submissionsPerDay— that cap is calibrated against what a LIVE submission costs in Discord API calls, and an imported row costs one INSERT and no Discord traffic at all — but they are capped byimportedSubmissionCeiling()(historyRetentionDays * submissionsPerDay), counted across every import into a form. A per-call row limit bounds one file, not a hundred different ones, andsite/index.htmlpublishes a promise that no cap anywhere is unlimited. Over-ceiling imports are refused whole and never truncated: importing part of a history quietly discards real applications. -
File-attachment resolution is defensive now, but still unproven against a live token.
importAppy.tsreads the upload throughresolveAttachment(), which handles a Collection, aMapand a plain object, and tries both BigInt and string keys — because attachment-option resolution shifts across library versions, like the gateway-wiring caveat inbot/src/core/client.ts.The previous single optional call,
resolved?.attachments?.get?.(id), did not throw on an unexpected shape. It returned undefined, and the user was told "Could not read the uploaded file" — which reads like their mistake rather than ours. That failure mode is gone, but the happy path still has not been smoke-tested against a live bot token. The dashboard route (POST .../migrate/appy-submissions, JSON body) shares none of this risk and is the safer path to demonstrate.
- Panel buttons are capped at 5 per action row (Discord limit). A panel needing more than 5 forms should be split into multiple panels — the dashboard's panel builder should enforce this at the UI level.
- Modals are capped at 5 components.
showApplicationModaltruncates and logs a warning if a form has more than 5 text questions; the dashboard form builder should warn the admin at creation time instead of silently truncating. - Bot role hierarchy: role grant/remove will silently fail for any role
positioned above the bot's own highest role in Discord's settings — this
is enforced by Discord itself.
permissionService.findUnmanageableRoleschecks this proactively so accept/deny can report it back to staff instead of failing invisibly. - The in-process
scheduler.tsassumes a single bot instance. If you run more than one bot replica, move scheduled poll publish/close to a distributed lock or a separate worker so it doesn't fire twice. - Discordeno version drift:
bot/src/core/client.tsdeliberately uses the high-levelbot.start()helper rather than hand-wiringcreateGatewayManager, since that lower-level API has changed shape across Discordeno releases. If you need multi-shard/multi-process gateway control, see the comment at the top of that file before reaching for the low-level API.