Promo Code Module Plan
Free-plan promo codes: admin management, claim-mode (once or unlimited), redemption tracking, quota bump, and email flows.
Promo Code Module Plan
Section titled “Promo Code Module Plan”Status: PLANNING (not implemented).
1) Goals
Section titled “1) Goals”- Admin management for promo codes (create, list, edit, and deactivate only).
- Database tables for codes and redemptions.
- Access control: the promo admin module (CRUD + listing with claim details) is visible only to the hardcoded allowlisted email
zainalam1001@gmail.com(normalized: trim + lowercase). No other user may load those routes or actions. - Redemption rules:
- Free plan only for now: paid shops cannot redeem (clear error message).
- A shop may redeem many different promo codes over time (each successful redeem adds to
free_credit_limit). - Each promo code must have a claim mode selected at create time:
once: globally single-use (first successful claim wins; no one else can claim it).unlimited: can be claimed repeatedly by many shops.
- Listing: on the promo admin list, for each code show claim details when redeemed: Name, Email, Shop (domain), Date claimed. Name/email should come from whichever user identity fields are already available in DB for that shop/session at claim time.
- Benefit: each code grants X free replies added to the shop’s free-plan monthly reply allowance (today:
shops.free_credit_limit, used withshops.free_credits_usedperapp/lib/limits.server.js). - Email: on successful claim, send (a) a confirmation to the shop owner email and (b) a notification to info@appifire.com (reuse Brevo path in
app/lib/email.server.js).
2) Current codebase anchors
Section titled “2) Current codebase anchors”- Free reply cap:
Shop.freeCreditLimit, usageShop.freeCreditsUsed; enforcement incheckReplyLimit/incrementFreeCreditsUsed(app/lib/limits.server.js). Monthly reset clears usage, not the cap (resetFreeCreditsUsedForFreePlanShops). - Email: Brevo transactional API via
sendBrevoTransactionalEmailpattern inapp/lib/email.server.js. - Admin shell:
app/routes/app.jsx(s-app-navlinks); new nav item must be conditional (only when viewer is promo admin). - Auth:
authenticate.admin(request)across app routes; sessions stored in PrismaSession(often offline, withemailfrequently null in dumps—see §5).
3) Data model (Prisma)
Section titled “3) Data model (Prisma)”3.1 PromoCode
Section titled “3.1 PromoCode”| Field | Type | Notes |
|---|---|---|
id | UUID | PK |
code | String | Unique; store normalized uppercase format for lookups |
freeReplies | Int | Number of replies to add to free_credit_limit on redeem |
description | String? | Internal label / notes |
claimMode | String | Required enum-like value: once or unlimited |
active | Boolean | Default true; inactive codes reject redemption |
createdAt / updatedAt | DateTime | Standard |
Optional later: expiresAt, maxRedemptions (if you want capped multi-use beyond unlimited).
Promo code format (fixed):
- Display format:
APPI-XXXX-XXXX - Charset: uppercase
A-Zand digits0-9 - Separator: hyphen (
-) every 4 chars for readability - Normalized storage: uppercase with hyphens removed (example:
APPI7K9Q4M2P) - Validation:
- Minimum 8 and maximum 16 alphanumeric chars after normalization
- Reject spaces/special characters
- Case-insensitive input accepted, always normalized before lookup
3.2 PromoRedemption
Section titled “3.2 PromoRedemption”One row per successful redemption.
| Field | Type | Notes |
|---|---|---|
id | UUID | PK |
promoCodeId | String | FK → PromoCode |
shopId | String | FK → Shop |
shopDomain | String | Denormalized for fast listing / email |
claimedByName | String? | Name captured at claim time from the best available DB-backed user/shop identity fields |
claimedByEmail | String? | Email captured at claim time from the best available DB-backed user/shop identity fields |
claimedAt | DateTime | Default now() |
Indexes:
- Index(
promoCodeId) — fast lookup for claim count and history by code. - Index(
shopId) — useful for support (“which promos did this shop redeem?”).
Concurrency (must-have): use a transaction:
- Load promo by normalized code with
active = true. - If
claimMode = 'once', reject when any redemption already exists forpromoCodeId(or enforce with DB uniqueness strategy). - If
claimMode = 'unlimited', skip the one-time check. - Insert redemption row.
- Increment
Shop.freeCreditLimitbyfreeReplies.
For once, keep DB-level protection against race conditions (for example a unique mechanism that guarantees one redemption max for that code).
Claim count tracking (required):
- Source of truth:
PromoRedemptionrows. - Per-code claim count:
COUNT(*) WHERE promoCodeId = ? - No promo reference on a single user row is needed because claims are one-to-many (a shop/user can claim multiple different promo codes).
- Optional optimization later: store a denormalized
claimedCountonPromoCodeand keep it synced in the same transaction.
3.3 PromoCodeSendLog (admin send-email audit)
Section titled “3.3 PromoCodeSendLog (admin send-email audit)”One row per admin-triggered “send promo code by email” action.
| Field | Type | Notes |
|---|---|---|
id | UUID | PK |
promoCodeId | String | FK → PromoCode |
recipientEmail | String | Destination email |
recipientName | String? | Optional |
customNote | String? | Optional |
sentByEmail | String | Admin identity (should be zainalam1001@gmail.com for now) |
sentAt | DateTime | Default now() |
providerMessageId | String? | Optional provider id for support/debug |
sendStatus | String | e.g. sent / failed |
sendError | String? | Error text when failed |
Indexes:
- Index(
promoCodeId) - Index(
recipientEmail) - Index(
sentAt)
4) Routes and UX
Section titled “4) Routes and UX”4.1 Merchant-facing: redeem
Section titled “4.1 Merchant-facing: redeem”- New route, e.g.
app/routes/app.promo-redeem.jsx(path/app/promo-redeem), authenticated for any shop user (still only freeshop.plan; see §6). - UI: text field + submit; success/error banners.
- Action: validate code (exists, active), confirm shop is free plan, apply claim-mode logic (
oncevsunlimited), resolve name + email from whichever identity fields are already present in DB for this shop/user context, then transaction + emails.
Placement: link from Billing & Usage (app/routes/app.billing.jsx) as “Have a promo code?” so merchants find it naturally.
4.2 Admin-facing: CRUD + list
Section titled “4.2 Admin-facing: CRUD + list”- New route(s), e.g.
app/routes/app.promo-codes.jsx(path/app/promo-codes), authenticated +requirePromoAdmin(request)(§5). - UI/style compliance: promo CRUD screens must follow existing Appifire application style guidelines and reuse established admin patterns (Polaris layout, spacing, form styles, table patterns, banners, empty states, and loading/error states).
- List: table of codes: code, free replies, active, created, and claim columns (Name, Email, Shop domain, Claimed date) or “Unclaimed”.
- List: table of codes: code, free replies, claim mode, active, Claimed count, created, and claim details (Name, Email, Shop domain, Claimed date) for recent/latest claims or drill-down.
- Create / edit: form fields: code (or auto-generate),
freeReplies, claimMode (onceorunlimited),description,active. - Deactivate only (no delete): admin can set
active = falseto stop new claims while preserving full history. Hard-delete is out of scope and should not be exposed in UI or server actions.
4.3 Admin-facing: send promo code by email
Section titled “4.3 Admin-facing: send promo code by email”- New route, e.g.
app/routes/app.promo-send.jsx(path/app/promo-send), authenticated +requirePromoAdmin(request). - Purpose: send a selected promo code to a target recipient email from Appifire admin.
- UI fields:
- Promo code selector (active codes only)
- Recipient email
- Optional recipient name
- Optional custom note
- Action:
- Validate promo code exists and is active.
- Validate email format.
- Send email using Brevo helper (
app/lib/email.server.js). - Write a send log row (see §3.3) for audit and support visibility.
- This screen only sends a code by email; it does not redeem/claim the code.
4.4 Navigation
Section titled “4.4 Navigation”- In
app/routes/app.jsx, render<s-link href="/app/promo-codes">Promo codes</s-link>only when loader or root layout knowsshowPromoAdminNav === true. - In the same gated nav, add
<s-link href="/app/promo-send">Send promo</s-link>for the email-send screen. - Implement via a small shared loader helper or pass flag from a parent loader (avoid N+1: optional dedicated endpoint or embed flag in
apploader after you add root loader data).
5) Access control: zainalam1001@gmail.com only
Section titled “5) Access control: zainalam1001@gmail.com only”Requirement: only that email may access promo admin routes and POST actions.
Reality check: with offline sessions, Session.email is often null (databases/*.sql examples). You must not rely on DB session email alone without verification.
Recommended approach (pick one and implement consistently):
- Session token / online identity (preferred): Use whatever
authenticate.adminexposes for the current staff user in your stack (session token claims or online session). Normalize email and compare to the allowlist (and optionally move allowlist toprocess.env.PROMO_ADMIN_EMAILScomma-separated while keeping your address as default). - Admin GraphQL: If the API version and scopes allow reading the authenticated staff member’s email, use that in the loader guard.
- Interim fallback: If email cannot be resolved, return 403 and log—do not fall open.
Promo redeem route: no email allowlist; any authenticated staff for the shop can submit a redeem for that shop, and persisted Name / Email on the redemption row and in notifications must use whichever DB identity fields are available (shop/user/session), with deterministic fallback order in implementation.
Server-side: every loader and action on /app/promo-codes must call requirePromoAdmin; do not rely on hiding the nav alone.
6) Quota behavior when redeeming
Section titled “6) Quota behavior when redeeming”-
Eligibility:
shop.plan === 'free'only. If the shop is on a paid plan (or any non-free plan you add later), return a clear error and do not mutate quota or create a redemption. -
In the same transaction as creating
PromoRedemption, run:freeCreditLimit: { increment: promo.freeReplies } -
Do not change
freeCreditsUsedon redeem (the merchant gets more headroom immediately). -
Multiple codes: the same
shopIdmay redeem different codes in separate transactions; each code still redeems at most once globally.
Cron / reset: resetFreeCreditsUsedForFreePlanShops only resets usage; higher freeCreditLimit from promos remains until you manually adjust or add separate logic.
7) Decided product rules (reference)
Section titled “7) Decided product rules (reference)”| Topic | Decision |
|---|---|
| Plan | Free plan only for now; paid shops cannot redeem. |
| Claim mode | Admin selects once (single-use globally) or unlimited (many claims allowed) at promo creation. |
| Reuse of codes | A shop may redeem many distinct codes over time. For a once code, only the first claim succeeds globally. |
| Promo code format | Use APPI-XXXX-XXXX display format; normalize to uppercase alphanumeric without hyphens in DB. |
| Claim tracking | Count claims from PromoRedemption by promoCodeId; no user-table promo reference required. |
| Name / Email on listing | Use whichever name/email is available in DB at claim time (shop/user/session identity fields). |
Still open for implementation detail: human-entered vs auto-generated codes; admin allowlist via env vs hardcode.
8) Email content
Section titled “8) Email content”Add helpers in app/lib/email.server.js (same Brevo requirements as welcome mail: BREVO_API_KEY, BREVO_FROM_EMAIL, etc.):
To merchant (recipient: claimedByEmail from redemption; fallback to DB shop email field if needed):
- Subject, e.g. “Your Appifire promo code was applied”
- Body: code (masked optional), X replies added, new monthly allowance (or “your cap was increased by X”), shop domain, support link.
- Subject, e.g. “Promo redeemed: {code} — {shopDomain}”
- Body: code, shop domain, captured claim name/email (from DB identity fields), free replies granted, timestamp.
Admin send screen email (new):
- Subject, e.g. “Your Appifire promo code”
- Body includes: promo code, free replies value, redeem instructions (
/app/promo-redeempath context), and optional custom note. - On each send attempt, persist
PromoCodeSendLogwith status/error details.
Fire-and-forget with logging on failure; redemption should not roll back if email fails (or make this explicit: if you require strong consistency, queue retries—default is usually “DB committed, email best effort”).
9) Implementation checklist
Section titled “9) Implementation checklist”- Prisma:
PromoCode,PromoRedemption+ migration. -
lib/promo.server.js(or similar): normalize code,redeemPromo({ shopId, code, claimedByName, claimedByEmail }),assertPromoAdmin(request); resolve claim identity from existing DB-backed user/shop/session fields (and validateplan === 'free'). -
app/routes/app.promo-redeem.jsx+ link from billing. -
app/routes/app.promo-codes.jsxCRUD + guarded loaders/actions. - Promo list query: include
claimedCountper code fromPromoRedemptionaggregation. -
app/routes/app.promo-send.jsx+ guarded loader/action for sending promo code emails. - Root nav: conditional Promo links (
Promo codes,Send promo). - UI review: promo screens must pass Appifire admin style-guideline compliance before merge.
-
email.server.js: merchant + internal notification. -
PromoCodeactions: remove any delete handler; allow deactivate only. - Prisma: add
PromoCodeSendLogtable for outbound send audit. -
PromoCodemodel/UI: addclaimModewith allowed valuesonce | unlimited. - Promo code validation: enforce format/normalization rules for create and redeem paths.
- Tests or manual QA (redeem): double-submit redeem, inactive code, wrong code, paid shop rejected, same shop two different codes OK, once-code second claim fails, unlimited-code multiple claims succeed, concurrent redeem race on
oncemode. - Tests or manual QA (send screen): invalid email blocked, inactive code blocked, send success logged, send failure logged.
- Document env vars if allowlist moves to configuration.