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
- Glossary
- Admin sections
- Project Edit
- Billing
- Post editor
- Import (RSS / Atom)
- Profile & 2FA
- What end-users see
- The widget
- Data flow cheatsheet
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
| Term | What it is |
|---|---|
| Project | One changelog. Has its own slug, custom domain, branding, posts, subscribers, plan. A Flarekit account can own many. |
| Post | One changelog entry. Draft → scheduled → published → archived. Owns translations, reactions, comments, an optional poll, an optional CTA button. |
| Category | Color-coded tag for a Post (e.g. “New features”, “Bug fixes”). Project-scoped. |
| Segment | A rule that limits which end-users see a given post (e.g. traits.plan = "pro"). Project-scoped. |
| End-user | Someone visiting the changelog/widget. Anonymous until your code calls Flarekit.identify({...}). Stored in end_users. |
| Subscriber | Someone who left their email on the public page to get post emails. Different table from end-users — they may or may not overlap. |
| Member | A teammate with access to your project. Role = owner / admin / editor / viewer. |
| Widget | The 1-line <script> you paste into your customer’s site. Renders posts, accepts reactions, etc. 5 modes. |
| Plan | Free / 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 oneend_userrow 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 thetraitsobject passed toFlarekit.identify(...))email matches,name containsurl_path starts_with /pricing(matched againstwindow.location.pathnameof 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:
- User submits email → row created with
confirmed_at = null. - Confirmation email sent.
- They click the link →
confirmed_at = now(). - 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.localeon thepost_viewevent. - 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:
eventstable (Postgres native partitioning by month) records everywidget_opened,post_view,post_reacted,link_clicked.- Hourly cron rolls them up into
project_stats_dailyandpost_stats_dailyfor 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) → planned → in_progress → shipped (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:
- Owner/admin types email + role → “Send invite”.
- If the email belongs to an existing Flarekit user → instantly added as accepted.
- Otherwise → row created with a one-shot 48-char token, invite email sent.
- 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:
- Connect a repository (e.g.
acme/product). - Pick triggers: “On new releases” and/or “On merged PRs”.
- If PR-triggered, optionally filter by label (e.g. only
changelog-labeled PRs). - 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_idon 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_localesis the allow-list.
Custom domain
- Lets the customer host their changelog at
changelog.acme.cominstead offlarekit.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 markcustom_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) andcontent_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
PostTranslationrow.
- 4 tone buttons —
- 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 inpost_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-shot —
email_sent_atis 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_aton 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:
- Click “Enable 2FA” → server generates a 32-char secret, encrypts it, stashes in the session.
- Scan the QR with 1Password / Google Authenticator / Authy / Bitwarden.
- Enter the 6-digit code → secret persists, 8 recovery codes are shown ONCE (save them).
- 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).
| Mode | What it looks like | When to use |
|---|---|---|
| Popup | Floating 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. |
| Sidebar | Slide-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. |
| Inline | Mounts post list straight into a <div> you put on the page. | Help center pages, dedicated /whats-new route in a SPA, FAQ pages. |
| Banner | Fixed strip across the top of the page with the freshest post. Dismissable. | Marketing pages, public landing pages — zero clicks needed. |
| Single | Renders 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.