Documentation

Flarekit handbook

How every section of Flarekit fits together — for co-owners onboarding the product and for users figuring out which tab does what.

Last updated: 2026-05-25T00:00:00.000Z

A walkthrough of every section of Flarekit — what it does, where its data comes from, where that data ends up, and why the section exists at all. Written so a co-owner can read it cold, and so future end-users can use it as their reference.

Table of contents


Architecture in 90 seconds

                                  ┌─ apps/api ──────────────────────────┐
   You (admin)  ──▶  app.*.io  ──▶│  Laravel 13 + Inertia + Vue 3 admin │
                                  │  Public changelog / roadmap pages   │
                                  │  /widget/v1/* JSON API              │
                                  │  Stripe + Resend webhooks           │
                                  └─────────────────────────────────────┘


              ┌─ apps/widget ──────────┐  ┌─ apps/landing ─────────┐
   Your end-  │ vanilla TS, ~13 KB gz  │  │ Astro static site      │
   users   ──▶│ 5 modes, polls, NPS,   │  │ flarekit.io marketing  │
              │ reactions, comments    │  └────────────────────────┘
              └────────────────────────┘
              embedded into your customer's site via 1 <script> tag

One app, two surfaces:

  • Admin (you + your team): writes posts, configures branding, watches analytics.
  • Public (your customer’s users): reads the changelog, reacts/comments, votes on the roadmap, leaves feedback. Embedded in your customer’s site via a script tag, or hits the hosted page c/{slug} directly.

Everything is multi-tenant by project_id — one Flarekit account can own many projects, each with its own changelog, branding, domain, subscribers and analytics.


Glossary

TermWhat it is
ProjectOne changelog. Has its own slug, custom domain, branding, posts, subscribers, plan. A Flarekit account can own many.
PostOne changelog entry. Draft → scheduled → published → archived. Owns translations, reactions, comments, an optional poll, an optional CTA button.
CategoryColor-coded tag for a Post (e.g. “New features”, “Bug fixes”). Project-scoped.
SegmentA rule that limits which end-users see a given post (e.g. traits.plan = "pro"). Project-scoped.
End-userSomeone visiting the changelog/widget. Anonymous until your code calls Flarekit.identify({...}). Stored in end_users.
SubscriberSomeone who left their email on the public page to get post emails. Different table from end-users — they may or may not overlap.
MemberA teammate with access to your project. Role = owner / admin / editor / viewer.
WidgetThe 1-line <script> you paste into your customer’s site. Renders posts, accepts reactions, etc. 5 modes.
PlanFree / Pro / Business. Sets limits + unlocks features. Trial = 15-day Pro.

Admin sections

Dashboard

What: Landing page after login.

Shows:

  • 4 quick stats for your current project: published posts, confirmed subscribers, widget MAU (last 30 days), last-post date.
  • “Welcome back” card with a CTA to install the widget or write the first post.
  • Onboarding checklist (4 steps). Each row is clickable, takes you to the right page. Item flips green automatically when the underlying state changes:
    • project — done when account is created.
    • post — done when you have at least one published post.
    • widget — done when at least one end_user row exists for the project (means the widget actually loaded somewhere).
    • team — done when at least one non-owner member is accepted.

Why: Single entry point that doubles as “what does this user need to do next.”


Posts

What: The full list of changelog entries for the current project.

Columns: title, slug, status (draft / scheduled / published / archived), labels, category, last-modified date.

Filters: by status, by category, full-text search across titles/slugs.

Actions:

  • “New post” → opens the Post editor (blank).
  • Click a row → opens the editor for that post.
  • “Import RSS” → opens Import for migrating from AnnounceKit / Headway / Canny / Beamer / WordPress.

Why: Backbone of the product. A team will spend most of their time here. Anything posted here flows out via email, the widget, the public changelog page, RSS feeds, integrations (Slack/Webhooks).


Categories

What: Color-coded tags assigned to posts (“New features”, “Bug fixes”, “Security”).

Per category: name, slug, color (hex), optional icon (sparkles / wrench / bug / shield / rocket / star / flame).

Why: Lets readers filter the public changelog (“Show me only releases”) and lets the widget badge each post in its category’s color. Setting a clear category palette upfront is the single biggest “this looks like a polished product” tweak.


Segments

What: Reusable filter rules (“Users on the Pro plan”, “Customers in EU”, “URL path starts with /pricing”).

Built with:

  • traits.<key> equals/contains/in <value> (matches against the traits object passed to Flarekit.identify(...))
  • email matches, name contains
  • url_path starts_with /pricing (matched against window.location.pathname of the visiting page — works for both authenticated and anonymous users)

How posts use them: in the post editor, attach one or more segments to a post. The widget then only shows that post to users that match at least one of the attached segments.

Why: Lets you announce a Pro-only feature without bothering free users; or announce a billing change only to paying customers; or show different posts on different pages of the same site.

Gating: Business plan only.


Subscribers

What: Email addresses opted in to your changelog.

Where they come from:

  • The subscribe form on /c/{slug} (public changelog page).
  • (Future) API endpoint for piping in from your own auth flow.

Per subscriber: email, locale, state (pending / confirmed / unsubscribed), digest frequency (instant / weekly / monthly), signup date, confirmation date, IP.

Filters: tab by state, search by email.

Actions: delete one, export all to CSV.

State machine:

  1. User submits email → row created with confirmed_at = null.
  2. Confirmation email sent.
  3. They click the link → confirmed_at = now().
  4. They click Unsubscribe in any email → unsubscribed_at = now(). Re-subscribing resets both timestamps.

Why: Email is still the highest-conversion channel for “your customer’s customer learns about a new feature”. This page is where you watch the list grow, and the CSV export is what you take with you if you ever leave Flarekit. GDPR-friendly — single-click delete also wipes any EmailDelivery rows for that subscriber.


Analytics

What: Dashboards over the last N days (7 / 14 / 30 / 90).

5 stat cards: widget opens, unique widget users (MAU-style), post views, reactions, link clicks.

Sections:

  • Time series chart — daily views + unique users + widget opens for the range.
  • Top posts table — ranked by views, with reactions / clicks columns.
  • Conversion funnel — visitors → confirmed subscribers → users who left at least one reaction in the same window.
  • By language — post views grouped by metadata.locale on the post_view event.
  • Cohort retention — last 8 weekly subscriber cohorts × 8 ahead-weeks, % of cohort that received at least one email each week. Color-coded heatmap.

Export: CSV for overview (per-day metrics) and per-post breakdown.

Where the numbers come from:

  • events table (Postgres native partitioning by month) records every widget_opened, post_view, post_reacted, link_clicked.
  • Hourly cron rolls them up into project_stats_daily and post_stats_daily for fast dashboards.

Why: Without analytics, posting feels like shouting into the void. Funnel + cohort retention specifically answer the two questions every founder asks: “are people seeing my updates?” and “do they keep coming back?”

Gating: Business plan only.


Roadmap

What: Kanban-style board (Planned / In progress / Shipped / Paused) of public roadmap items.

Each item: title, description, category, optional ETA, optional link to a published Post (so “Shipped” items can be clicked through to the changelog entry).

Public visibility toggle — items can be marked private (you see them in admin, public visitors don’t).

Admin UI: drag-free reorder by sort_order field, edit-inline, status dropdown, “Public on roadmap” checkbox.

Public page: /c/{slug}/roadmap — 3-column board, “Suggest an idea →” link at the bottom takes visitors to Feedback.

Why: Shows your audience what’s coming, builds trust, reduces “is X on the roadmap?” support load. Customers happily pre-commit to plans they can see progressing.


Feedback (Feature requests)

What: A public board where your customer’s users submit and upvote ideas.

Lifecycle: pending (just submitted, hidden until you triage) → open (visible publicly, accepting votes) → plannedin_progressshipped (or declined / duplicate).

Each request: title, description, submitter (name + email if provided), vote count, status, optional link to a roadmap item.

Admin UI: filter by status, change status from a dropdown, see total votes.

Public submission form lives at /c/{slug}/feedback:

  • Honeypot field to silently swallow bot submits.
  • Throttled to 5 submits / minute / IP.
  • Voting toggles (one vote per anon cookie per request).

Why: Cheap alternative to Canny ($400/mo) for early-stage products. Reduces “what should we build next” guesswork — users literally rank it for you.


Surveys

What: Standalone in-product survey widgets (NPS / CSAT / thumbs).

3 types:

  • NPS — “How likely are you to recommend us?” → 0-10 scale. We auto-compute the NPS score on the response list page (promoters % − detractors %).
  • CSAT — 1-5 satisfaction scale.
  • Thumb — up/down binary.

Each survey has a slug. To trigger it, your site adds:

<script ... data-mode="survey" data-survey="launch-nps"></script>

The widget pops a card asking the question + collecting an optional comment. The visitor only sees it once — survey_responses checks for an existing row by anonymous cookie + external id.

Admin UI: list of surveys (active/paused toggle), embed snippet, response breakdown (histogram + NPS auto-computed), per-response comment view.

Why: Letting users grade what you just shipped without leaving their app gets 5-10× response rate vs. a follow-up email. Pairs naturally with a post — ship feature X, drop the survey on the next page they hit.


Comments

What: Discussion under public changelog posts.

Lifecycle: pending (moderation queue) → approved (visible publicly) or rejected.

Where they’re created: the form on /c/{slug}/{post-slug}. Anonymous-friendly (optional name + email, with honeypot + throttle).

Admin UI: tabbed by status with totals, one-click approve/reject/delete.

Author notifications: every hour a cron mails the post author a digest of:

  • new reactions on their posts since last tick
  • new pending comments awaiting their review

Bumps users.last_author_notify_at so you never get re-notified about the same activity.

Why: Letting users discuss a release builds community, but unmoderated comments turn nasty fast. Moderation queue is the floor below which we don’t go.


Team

What: Invite teammates to collaborate on a project.

Roles:

  • Owner — everything, including delete + billing. Only one per project.
  • Admin — everything except delete + billing.
  • Editor — write & publish posts, manage categories, view analytics.
  • Viewer — read-only access.

Invite flow:

  1. Owner/admin types email + role → “Send invite”.
  2. If the email belongs to an existing Flarekit user → instantly added as accepted.
  3. Otherwise → row created with a one-shot 48-char token, invite email sent.
  4. They click the link in the email:
    • If they’re already logged in with the matching email → accepted, redirected to dashboard.
    • Otherwise → token stashed in session, they’re bounced to /register. When they sign up with the invite email, the listener auto-accepts.

Tokenized invite URLs are copyable directly from the admin page — useful if Mailpit/Resend hiccup.

Why: Most companies have at least a product manager AND a developer who want to ship changelog entries. Hard-coupling a project to a single user account is the #1 churn-source in B2B SaaS.


Integrations

What: Inbound integrations that pull data into your changelog automatically.

Currently: GitHub auto-draft.

Setup:

  1. Connect a repository (e.g. acme/product).
  2. Pick triggers: “On new releases” and/or “On merged PRs”.
  3. If PR-triggered, optionally filter by label (e.g. only changelog-labeled PRs).
  4. Copy the webhook URL + secret into your repo’s GitHub settings.

What happens then: every time GitHub fires release.published or pull_request.closed (merged), our webhook receives the payload, verifies HMAC, calls AI (OpenAI gpt-4o-mini) to summarize the body into clean HTML + a 1-sentence excerpt, and creates a draft post linked to the source URL.

Why drafts, not auto-publish: would be a foot-gun. You always want a human to glance at the wording before customers see it.

Future integrations: GitLab, Linear, Jira, Notion, Productboard. Same pattern (per-project secret, HMAC, AI summary, draft post).


Webhooks

What: Outbound integrations that push events out when something happens in your project.

Two types:

  • Slack-format — POST to a Slack/Discord/Mattermost incoming-webhook URL with their JSON blocks shape.
  • Generic — POST arbitrary JSON to any URL, signed with X-Flarekit-Signature: sha256=<hmac> so the receiver can verify the secret.

Events: currently only post.published. (Future: post.scheduled, subscriber.subscribed, feature_request.submitted, …)

Each endpoint: type, URL, optional label, optional event filter (subscribe only to specific events; null = all), secret (auto-generated whsec_*), active/paused toggle.

Audit trail: every delivery attempt is recorded in webhook_deliveries with HTTP status code, attempt #, error message, delivered timestamp.

Retries: queue job with tries = 4 and exponential backoff (10s → 60s → 300s → 600s).

Test button: dispatches a synthetic post.published event so you can verify the receiver works without publishing a real post.

Why: Lets your team ship a custom flow without us implementing each one — drop a Zap or n8n on the receiver and you’ve got “every published post → tweet, → Notion row, → ClickUp ticket” in 5 minutes.


Projects (switcher / list)

What:

  • The dropdown at the top of the sidebar shows every project you own or are a member of. Switching changes the current_project_id on your user and reloads the dashboard against that project.
  • The “Projects” sidebar item is the full list — useful when you want to bulk-glance which project has which plan / custom domain status.
  • “New project” button creates a fresh project (subject to plan project-count limit).

Why: Agencies, multi-product companies, and the “test-vs-prod” workflow all need >1 project. Switcher is friction-free.


Project Edit

/projects/{id}/edit — the kitchen-sink configuration page for a project. Three logical groups.

Identity (name, locales)

  • Name — internal label.
  • Default locale + enabled locales — which languages the post editor lets you write translations into, and which lang the public page falls back to. Visitor’s lang is picked from URL ?lang=, enabled_locales is the allow-list.

Custom domain

  • Lets the customer host their changelog at changelog.acme.com instead of flarekit.io/c/acme.
  • Two-step flow: type the domain → we generate a verification token → customer adds CNAME proxy.flarekit.io + TXT record → click “Verify DNS” → we resolve the records and mark custom_domain_verified_at.
  • On the prod box, Caddy on-demand TLS issues a Let’s Encrypt cert for that hostname automatically (when wired — see DEPLOY.md).
  • Gating: Pro plan or higher.

Install the widget

  • Shows the current widget mode (Popup / Sidebar / Inline / Banner / Single) as a badge.
  • The install snippet adapts to the selected mode — for sidebar shows a trigger button + data-selector; for inline/single shows a container <div>; for banner shows the minimal one-liner.
  • “How {mode} mode works” — pronounced instructions list under the snippet, explains what the user has to do to actually make this mode work on their site.
  • “Preview here” button injects the real widget into the current page so you can click around. Switching mode in the form while preview is running re-mounts the widget.
  • Collapsible “Raw API key” (regenerate button) and “Optional: identify your users” (sample snippet).

Branding & widget

  • Primary color — applies to widget buttons + email CTAs + public-page accents.
  • Logo URL — shown in email headers + public-page top bar.
  • From name — overrides “Flarekit” in the From: of every email.
  • Footer text — appended to every email (CAN-SPAM compliance, company address).
  • Widget mode — see Five modes.
  • Theme — light / dark / auto (auto reads prefers-color-scheme).
  • Open posts — links inside the widget open in-widget vs in a new tab to your changelog page.
  • Badge color — the dot above the bell with the unread count. Default red, change if it clashes.
  • Widget label overrides — replace “What’s new” / “View all” / “No updates yet.” / “Powered by Flarekit” with your own copy. Leave blank → falls back to the translated default for the visitor’s locale.
  • Custom CSS — raw CSS injected into the widget’s Shadow DOM. Use it to restyle anything that the controls above don’t expose. Scoped — won’t leak into the host page.

Billing

/billing

  • Plans grid — Free / Pro / Business with feature checkmarks per row.
  • Monthly / annual toggle — annual price is 20% off the monthly × 12.
  • Promo code field — applied at Stripe checkout via withPromotionCode.
  • 15-day Pro trial — automatic on registration (users.trial_ends_at = now() + 15d). During trial, PlanGate::userPlan(...) upgrades the user to Pro silently, so all paid features just work. The banner shows N days left.
  • Usage card — current project’s posts used / quota, current MAU / quota.
  • “Manage subscription →” redirects to Stripe Billing Portal once they have a customer ID (downgrades, swap card, view invoices).
  • Dev bypass (only in local env, behind BILLING_DEV_BYPASS=true) — clicking “Buy” applies the plan instantly without going through Stripe. Yellow banner reminds you.

What gating actually means: PlanGate is a single class. Every paid-feature controller calls $gate->assertHasFeature($project, 'feature_name', Plan::Pro). If the project’s plan doesn’t have the feature AND the project owner isn’t on a higher user-scope plan (trial), the controller throws PlanLimitException which the global handler redirects to /billing with a friendly message.


Post editor

/projects/{id}/posts/{post}/edit — the big one.

Main column:

  • Locale tabs — appear if the project has more than one enabled locale. Lets you toggle between writing the default-language version and translations.
  • Title + slug — slug auto-generated by Spatie HasSlug, project-scoped uniqueness.
  • Excerpt — short summary; shows in lists, emails, and the meta description on the public page for SEO.
  • Cover image — uploaded to Cloudflare R2 via the UploadController. Shown in email + public page.
  • Body (Tiptap WYSIWYG) — full rich editor with images, links, code blocks, lists, tables. Output stored as both content_html (rendered) and content_json (editor doc, used to round-trip when you re-open).
  • AI panel under the editor:
    • 4 tone buttons — formal, casual, concise, marketing. Click → server calls OpenAI to rewrite the current body in that tone.
    • On a non-default locale tab — “Translate to XX” button. OpenAI translates the post into that locale and stores it as a PostTranslation row.
  • CTA button — optional label + URL. Rendered as a primary button in the widget + on the public page.
  • Poll (sidebar form) — question, 2-8 options, single-choice or multi-select, optional close-time. Stored in post_polls + post_poll_options, votes in post_poll_votes. Widget renders it under the post body with a progress-bar view once the user votes.

Sidebar:

  • Status + actions — draft / scheduled / published / archived. “Publish” button.
  • Category — dropdown.
  • Labels — chips (new / improved / fixed / security / announcement / release). Multi-select.
  • Scheduled at — datetime picker, shown only for “scheduled” status.
  • Segments — multi-select, only shown if project plan has segmentation.

Important behaviour:

  • Email blast is one-shotemail_sent_at is set when a post is published for the first time. Un-publish + re-publish doesn’t blast again, so you can fix typos without spamming subscribers.
  • Widget feed cache invalidates on every save via a tag-bump counter in Redis. New posts appear within seconds for users currently viewing.

Import (RSS / Atom)

/projects/{id}/import

Two modes:

  • Feed URL — paste a public RSS or Atom URL (we fetch with Http::timeout(10)).
  • Paste XML — for tools that don’t publish a feed; paste raw XML, we parse it the same way.

What it does:

  • Parses RSS 2.0 (<item>) and Atom (<entry>).
  • Creates one draft post per item.
  • Auto-detects categories from <category> tags, creates them with a palette color if they don’t exist.
  • Dedupes on posts.meta.source_url — re-running an import on the same feed is a no-op.
  • Sets email_sent_at = published_at on imported posts so they never trigger an email blast (historical posts shouldn’t notify anyone).
  • Strips <script> defensively.

Known feeds that work great: GitHub releases atom (https://github.com/{owner}/{repo}/releases.atom), Cloudflare blog RSS, AnnounceKit XML exports, Headway exports, generic WordPress feeds.

Why drafts: see Integrations — same reason. Always a human review step before going public.


Profile & 2FA

/profile — name, email, password, delete account.

/two-factor — TOTP enrollment:

  1. Click “Enable 2FA” → server generates a 32-char secret, encrypts it, stashes in the session.
  2. Scan the QR with 1Password / Google Authenticator / Authy / Bitwarden.
  3. Enter the 6-digit code → secret persists, 8 recovery codes are shown ONCE (save them).
  4. Disable button removes secret + recovery codes.

On login, if a user has 2FA enrolled, they hit /two-factor-challenge after username+password. Enter the code or one of the recovery codes (used once).

Why: Account takeovers in changelog tools mostly hurt your customers — bad actor publishes a “we got hacked” post or a malicious link. Token-based 2FA on the owner account makes that one-step harder.


What end-users see

These pages live under /c/{project-slug} (or under the customer’s custom domain if configured). They’re the customer-facing part of Flarekit.

Public changelog

/c/{slug} — paginated list of published posts.

  • Brand bar (logo + name) at top.
  • Locale switcher (if >1 enabled locale).
  • Category filter pills.
  • Per-post: meta line (category + date + labels), title, excerpt, reaction bar.

/c/{slug}/{post-slug} — individual post page.

  • Full body.
  • Prev/next navigation.
  • Reaction bar (6 emoji).
  • Comment thread + comment form (honeypotted).
  • JSON-LD schema for SEO.

Anon identity is tracked via a long-lived fk_anon_id cookie. We don’t link it to any personal data unless the user submits a subscribe form or signs up for the customer’s product and your code calls Flarekit.identify(...).

Public roadmap

/c/{slug}/roadmap — 3-column kanban of public roadmap items (planned / in progress / shipped).

Each card: category badge, title, optional description, optional ETA. Shipped items with a linked post link out to that post.

Public feedback board

/c/{slug}/feedback — list of user-submitted ideas + voting + submission form.

  • Two sort modes: Top (by vote count), New (by recency).
  • Each card: vote button (with count), title, excerpt, status badge, submitter (if shared).
  • Submit form at the bottom (title required, description optional, name + email optional).
  • Pending submissions hidden from the public — admin triages.

Email a subscriber receives

Three types:

  • Confirm subscription — sent right after signup. Single CTA: “Confirm subscription”. Required by anti-spam regulations.
  • New post — sent when a post is published, to all instant-frequency subscribers. Includes the post title, excerpt, cover image (if any), and the CTA button (if defined on the post). Falls back to a “Read the full update” button.
  • Digest — weekly or monthly, sent by cron, contains every post since the subscriber’s last digest. Subscribers pick this preference at signup.

All three are localized to the subscriber’s locale. The from-name + footer + primary color all reflect the project’s branding settings.

List-Unsubscribe header + ?token=... one-click unsubscribe link in every email (Gmail compliance).


The widget

Embedded into your customer’s site via:

<script async src="https://app.flarekit.io/v1/widget.js"
  data-project="pk_xxx"></script>

Renders into a shadow DOM, so the host site’s CSS can’t reach in (or out — your widget styles don’t leak).

Five modes

Selected via data-mode on the script tag, or set server-side in the Branding & widget panel (server setting wins unless data-mode is explicitly set on the tag).

ModeWhat it looks likeWhen to use
PopupFloating bell in a page corner with unread-count badge. Click → panel with post list.Default. Good for SaaS dashboards, anywhere a global “what’s new” hint makes sense.
SidebarSlide-in drawer from the right edge. No bell — you trigger it from your own button via data-selector or Flarekit.open().When you already have a “Help” or “What’s new” menu item in your UI.
InlineMounts post list straight into a <div> you put on the page.Help center pages, dedicated /whats-new route in a SPA, FAQ pages.
BannerFixed strip across the top of the page with the freshest post. Dismissable.Marketing pages, public landing pages — zero clicks needed.
SingleRenders one specific post (by data-post=ID) inline.Onboarding tours, feature highlight pages, “/new-feature” landing.

Booster

Programmatic API:

Flarekit.attachBooster('#new-feature-button', { postId: 42, label: 'New' });

Attaches a small pulsing dot to any DOM element. Click the dot → opens the changelog panel + marks itself dismissed (persisted in localStorage by selector × postId). Each user sees a given booster at most once.

Why: points the user’s attention at the actual UI affordance you just shipped, instead of expecting them to read a changelog. Conversion to feature-discovery is ~3-4× higher than passive entries.

Surveys via widget

data-mode="survey" data-survey="my-nps-slug" mounts a card with the survey question. Visitor responds → one row in survey_responses. After they respond, future page loads silently skip (won’t re-bother them).


Data flow cheatsheet

What goes where, in plain English:

WRITE PATH (admin)

  You type a post in /posts/{id}/edit

    ├─ Save                       → posts table (status=draft|scheduled|published)
    ├─ Translate (AI)             → post_translations
    ├─ Add poll                   → post_polls + post_poll_options

    └─ Publish

         ├─ Set email_sent_at → SendPostToSubscribers job
         │        └─ Per subscriber: queue NewPostMail

         ├─ Post::fireOutgoingWebhooks → DispatchWebhook per endpoint
         │        ├─ Slack-format payload → POST to Slack URL
         │        └─ Generic-format       → POST signed with HMAC

         ├─ Cache::increment("widget:feed:bump:{$projectId}")
         │        └─ widget /feed cache invalidates within 1 minute

         └─ Public page now serves the post at /c/{slug}/{post-slug}


READ PATH (end-user, widget)

  User opens page with <script data-project="pk_xxx">

    └─ widget.js boot

         ├─ GET /widget/v1/feed?anonymousId=...&urlPath=/pricing
         │        │
         │        ├─ Resolves EndUser (creates row if first visit)
         │        ├─ Runs SegmentEvaluator with traits + url_path
         │        └─ Returns project config + posts + unread count

         └─ Renders mode-appropriate UI in shadow DOM

              ├─ User reads a post → POST /posts/{id}/view → PostView row
              ├─ User reacts        → POST /posts/{id}/react → PostReaction
              ├─ User votes in poll → POST /posts/{id}/poll/vote → PostPollVote
              └─ User answers survey → POST /surveys/{slug}/respond → SurveyResponse


SUBSCRIBE PATH

  /c/{slug}      user types email + frequency

    └─ POST /c/{slug}/subscribe → subscribers row (confirmed_at=null)
         └─ ConfirmSubscriptionMail
              └─ user clicks link → confirmed_at=now()
                   ├─ instant-frequency:      every published post triggers NewPostMail
                   └─ weekly/monthly digest:  cron picks them up, sends DigestMail


FEEDBACK PATH

  /c/{slug}/feedback   user submits idea

    └─ feature_requests row (status=pending) + first vote

         └─ admin moderates → status=open (visible publicly) / declined / duplicate

                                 └─ public visitors vote → feature_request_votes

That’s the whole product. Everything else (analytics aggregation, author digests, scheduled publishing) is a cron running on top of these tables.