Skip to content

Latest commit

 

History

History
418 lines (341 loc) · 131 KB

File metadata and controls

418 lines (341 loc) · 131 KB

MAP.md — Codebase Architecture

Single source of truth for the structure of topiqu-blog.

1. Stack

  • Framework: Nuxt 4 (Vue 3.5, Composition API), TypeScript strict.
  • Styling: UnoCSS (uno.config.tspresetWind3 + presetTypography), SCSS partials. presetTypography is what makes the prose* utilities real. The article body (pages/clanky/[slug].vue) has always been marked up with prose prose-gray prose-h2:… prose-blockquote:… dark:prose-invert, but only presetWind3 was registered, so every one of those classes compiled to nothing and article HTML rendered unstyled — most visibly tables, which arrived from the AI with no borders, padding or header contrast and read as loose columns of text. cssExtend adds the table rules (table, thead th, tbody td). Note the colour vars are named --un-prose-th-borders / --un-prose-td-borders — there is no --un-prose-borders or --un-prose-bg-soft, and referencing a non-existent one silently voids the whole declaration (i.e. no border at all), so verify generated output rather than assuming a var exists. A class only earns CSS if UnoCSS scans the file it is written in — Vite's defaultPipelineInclude covers .vue/.tsx but not plain .ts, and @unocss/nuxt only ever widens exclude. Moving the body classes into shared/utils/articleProse.ts therefore un-styled the whole article a second time, by a different mechanism than the missing preset: measured over exactly the files the default pipeline scans, no prose rule is emitted at all (.prose appearing inside JS selector strings in Article/TOC.vue does not count as a token). uno.config.ts now sets content.pipeline.include to the default regex plus shared/**/*.ts; tests/unit/articleProse.test.ts asserts both that the path matches a scanned pattern and that the thead th/tbody td border rules actually generate.
  • Tables leave prose rather than fight it (shared/utils/articleProse.tsARTICLE_TABLE_CLASS / EDITOR_TABLE_CLASS). Every typography rule is :where(…):not(:where(…)) — specificity 0,0,0 — so the preset's own later rules win on source order alone: :where(tbody td, tfoot td) overrode the cssExtend cell padding and :where(thead th:first-child) zeroed the inline padding of the outer columns, leaving text against the border. Winning that race means matching the preset's internal selector strings, which is not a contract; not-prose is. Article/Parsed.vue wraps every top-level <table> in ARTICLE_TABLE_CLASS; published tables use a distinct header, row separators, zebra striping, hover feedback and a horizontally scrollable framed surface. EDITOR_TABLE_CLASS re-uses the same cells on Tiptap/Editor.vue's EditorContent and adds the chrome only TipTap emits (.tableWrapper, .selectedCell, .column-resize-handle); the editing surface had no table styling at all. The TipTap Table/TableRow/TableHeader/TableCell extensions were already registered — what was missing was the toolbar (insert, plus row/column/header commands shown only inside a table) and colwidth was already whitelisted in sanitize.ts. Tests assert every token in both class lists actually emits CSS: an arbitrary variant UnoCSS cannot parse fails silently.
  • presetTypography is the article's only vertical rhythm, because base.scss opens with * { margin: 0 }. While the prose CSS was dead, the editor's blank <p></p> was the sole spacing — hence the ZWSP p:empty::before that forced it to occupy a line box. With real margins that paragraph stops self-collapsing and every blank line costs a line box plus two margins, so ARTICLE_PROSE_CLASS hides it ([&_p:empty]:hidden) and tightens the preset's essay-width hr margin. :empty catches only one of the three spellings. articleSchema.content's describe() asked the model for a <br> at the end of every paragraph — correct while the body was raw v-html, dead weight once the preset owned the rhythm — so generated bodies are full of <p><br></p> and <p>text<br></p>, and neither is :empty (a <p> holding a <br> has a child). Fixed at the source (dropBlankLines in finalizeArticle, plus the schema now forbids the padding) and in CSS for already-saved rows: [&_p:has(>br:only-child)]:hidden [&_p>br:last-child]:hidden. Images are prose-img:my-0, not my-6finalizeArticle emits <p style="text-align:center"><img>, so the paragraph's margin already spaces them, and <img> is a replaced element whose vertical margins apply even inline, so anything here stacks rather than replaces ([slug].vue's .prose p img padding was a third layer). Keep space-y-* off that container: .space-y-6 > :not([hidden]) ~ :not([hidden]) is 0,3,0 against prose's 0,2,0, so it outranks every margin below it and levels headings with paragraphs.
  • Reading progress is scoped to the article body, not the full page. useArticleScrollContext measures the body rectangle against the active window/dashboard scroller, so comments, sources, related cards and the footer do not pin progress near the beginning and then force a jump to 100% at document bottom.
  • State: Pinia (@pinia/nuxt) + pinia-plugin-persistedstate; Pinia Colada (@pinia/colada + @pinia/colada-nuxt) for admin-side data fetching and cache invalidation, see Data-fetching split below. There is no event bus.
  • i18n: @nuxtjs/i18n (en, cs).
  • Auth: @sidebase/nuxt-auth, argon2 hashing, OTP (otplib).
  • DB / ORM: Prisma 6 + ZenStack v2 (domain-split prisma/schema.zmodel + prisma/models/*.zmodel → generated schema.prisma, ~50 models). See §6.
  • Editor: Tiptap 3 (custom extensions in extensions/: Poll, slashCommand, indent).
  • AI: Vercel ai SDK with @ai-sdk/xai + @ai-sdk/google + @ai-sdk/openai — article generation, sentiment, and per-language article translation (§6 → Article Translations). All model IDs live in one place: server/utils/ai/modelRegistry.ts (pure data + aiModelId/aiModelProvider, unit-tested in tests/unit/aiModels.test.ts), resolved to provider clients by server/utils/ai/models.ts (aiModel/aiImageModel). Never hardcode a model string at a call site — that includes the ArticleTranslation.model audit column, which reads aiModelId('translation'). Provider singletons: ai/xai.ts, ai/googleAi.ts, ai/openai.ts. Current split — Gemini 3.1 Pro (gemini-3.1-pro-preview) writes articles + LinkedIn copy, Grok 4.3 does research/sentiment/insights, GPT-5.6 Luna (gpt-5.6-luna) does translation + prompt enhancement, Nano Banana 2 Lite (gemini-3.1-flash-lite-image) generates images. Rationale: prose quality where the client sees it, cheapest capable model for token-heavy mechanical work. gemini-3.1-pro-preview is a preview ID — re-check it before it moves to GA or is retired.
  • Manual generation streams: server/utils/ai/article.ts splits into researchTopic (live-web facts and URLs plus a dedicated, oEmbed-verified YouTube lookup when selected) / buildArticleConfig (Prisma preferences + research-only factual grounding + articleSchema) / finalizeArticle (licensed Wikimedia/Openverse lookup with progressively shorter archive queries, generated fallback only where editorially safe, then numbered media-slot replacement) / blocking and streaming writers. POST /api/articles/generate emits NDJSON progress, finalized content, then an authoritative billing event with the new balance. The editor applies that balance immediately, refreshes the shared client status after the stream, and keeps a persistent result card showing delivered/missing modules, sources, media, duration and token usage. Billing remains disconnect-safe and Stop still bills only actual provider usage.
  • Scheduled editorial variety stays inside the existing topic-selection call. server/utils/ai/formats.ts holds six broad formats, several dramaturgical variants per format, and each format's eligible optional modules (answer, takeaways, FAQ, poll, table, body images, YouTube). topicSchema selects format + variant + up to three modules; the writer receives the variant's progression, while applyFormat is the hard boundary that clears unselected structured modules, including body-image instructions. Selecting body images requires 1–4 matching [[IMAGEn]] slots; without that module the manual formatted flow emits none. The cron feeds the picker the last 12 realised format / structureVariant / modules signatures and stores only the nullable Article.structureVariant; module history remains derivable from article fields/content. Search Console opportunities enter as at most five best-effort prompt lines: missing/broken GSC is neutral and never stops generation or becomes the editorial strategy. AI-selected YouTube uses [[VIDEO1]]; shared/utils/youtube.ts accepts known YouTube URL shapes, reduces them to an 11-character id, and the server emits its own privacy-enhanced embed — model-provided iframe HTML is never trusted.
  • Search Console Autopilot is an explicit write opt-in, gated by active SEARCH_CONSOLE + AI features (PREMIUM; configurable CUSTOM). The nightly search-console-sync stores finalised date/page/query performance, then search-console-autopilot atomically claims each opted-in connection and changes at most one published article per day. A page-one query with ≥200 impressions and CTR below 2% may rewrite only title+excerpt; a position-4–20 query may append one focused section. Audit history enforces seven days between writes to one article and 30 days for the same article/query pair. Existing article URLs are removed from the new-article signals sent to generate-article, preventing the obvious cannibalisation path; growing uncovered queries may reprioritise that cron but cannot override focus/novelty. Every write stores before/after data in the immutable audit chain, invalidates feeds, stales translations and exposes a guarded rollback that refuses to overwrite later human edits. Losing either feature disables the opt-in, so it never silently resumes after a downgrade.
  • Image model note. articleImage moved off Imagen 4 Fast because Google shuts the Imagen family down on 2026-08-17; tests/unit/aiModels.test.ts now fails on any imagen-* id so it cannot come back. Nano Banana 2 Lite is a Gemini image model (:generateContent), not an Imagen one (:predict) — google.image() fronts both, so models.ts is unchanged, but Gemini image models ignore n/maxImagesPerCall (dropped from ai/image.ts) and return PNG rather than WebP, which imageExtension(output.image.mediaType) already handles.
  • Cloud / Infra: AWS S3, Rekognition, SES; Stripe (@unlok-co/nuxt-stripe); Vercel deploy (vercel.json) — migrating to Dokploy/VPS, see MIGRATION.md; container image at Dockerfile (multi-stage bun, node-server preset via NITRO_PRESET); PWA (@vite-pwa/nuxt). No browser runtime: OG images render via @takumi-rs/* (Rust/WASM) and both PDF routes via pdfkit — there is no Playwright/Chromium/jsPDF in the stack, and playwright-core was removed as a dead dependency. Don't reintroduce a headless browser without revisiting MIGRATION.md §1.
  • Cache / Redis: Upstash Redis (HTTP/REST, serverless-friendly) — cross-instance cache-aside in server/utils/cache.ts. Provisioned via the Vercel Upstash marketplace integration, which injects UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN. Vercel-coupled dependency: on a hosting migration these creds must be re-provisioned/rotated (and the integration replaced by direct Upstash or another Redis-compatible provider). The cache degrades gracefully — missing creds disable caching, they don't break requests.
  • SEO: @nuxtjs/seo, nuxt-og-image, nuxt-gtag.
  • Security: nuxt-security, isomorphic-dompurify, @zxcvbn-ts/core, fingerprintjs.
  • Email: mjml templates in emails/, sent via SES (server/utils/sendEmail.ts).
  • Observability: @sentry/nuxt (error tracking + session replay), ingested by Better Stack.

2. Top-Level Layout

app/            Nuxt app layer (Vue UI)
server/         Nitro server (API + tasks + utils)
shared/         Cross-cut code shared by app & server (zod schemas, utils)
prisma/         ZenStack source, generated Prisma schema, migrations
extensions/     Tiptap editor extensions
emails/         MJML email templates
docs/           Consumer-facing docs (external-api.md — the public API spec)
scripts/        Dev-only verification scripts (ai-smoke, seed-tokens)
i18n/           Locale files (en, cs)
types/          Ambient TS types
public/         Static assets
todo/           Working notes (non-code)

3. App Layer (app/)

  • pages/ — 15 route files; Czech-language URLs (autor, autorizace, clanky, stitky, uzivatel, master, drafts). Admin section under admin/. The stitky/[slug] and autor/[name] listings share one presentational component Article/Collection.vue (search/sort bar + skeleton + horizontal card grid + hasMore-driven pagination); each page only owns its fetch, header, and SEO/JSON-LD.
    • Both listing headers are identity headers, not sentences. They previously led with "Articles by author" + name / "Articles with tag" + name, which restates what the surrounding page already is. autor/[name] now renders a real profile header — <UserPicture size="xl"> (which owns the initial/icon fallback, so an author with no upload still gets an avatar instead of the v-if-collapsed NuxtImg that made the page look broken), the name as the h1, the bio — and stitky/[slug] renders #tag (the # is aria-hidden, so the accessible name stays the bare tag). The removed copy is replaced by information: both endpoints return a policy-scoped total (db.article.count under enhanced Prisma, so drafts stay invisible to non-owners) rendered via the pluralized articles.articlesCount. total is deliberately unfiltered by search — it describes the author/tag, not the current query, so it does not flicker while typing.
      • While there: api/tags/slug/[slug].ts selected each article's author with user: { omit: { password: true } }, which only drops the @omit-ed secrets and shipped the rest of every author row (email, emailVerified, lastLogin, role, clientSiteId) into the SSR payload of a public tag page. ArticleCardData only ever reads user.username, so it is now an explicit select: { username: true }, matching by-author. Rule of thumb this encodes: omit is a deny-list and leaks by default — public projections must be select.
    • index.vue — tenant homepage. Every content block is empty-state guarded, so a client site with zero articles renders a short intentional page instead of a broken one: the featured/recommended grid renders only while featPending or when there is something to show (previously Article/SkeletonCard.vue fell into its loaded branch with article === undefined and emitted a ghost card linking to clanky-slug with slug: undefined); the sticky search + tag bar renders only with content or an active filter; the poll/top-articles section and its headings drop out entirely when both are empty. No content at all is handled one level up (dedicated empty page, below), so inside the feed the only terminal state left is filter matched nothing (articles.noResults.message). latestArticle is one sorted computed feeding both the hero link's slug and title, replacing two computeds that each re-sorted the whole list and carried unreachable common.noItems fallbacks (the template only reads them behind a non-empty guard).
      • The default layout publishes the tenant theme as --client-accent; homepage editorial accents and Article/SkeletonCard.vue tag/hover colors consume that variable instead of shipping a fixed orange brand over every client site.
      • A zero-article tenant renders a dedicated page, not a hole in the feed. isBlankSite (feat.totalArticles === 0, read synchronously from the already-awaited blocking featured fetch) short-circuits the whole template to Article/Empty/Page.vue — hero, search/tag bar, feed, poll/top-articles and the login block are never mounted. Because the signal is SSR-available and payload-transferred, the branch is decided in the first HTML and never flips on hydration (no CLS); the feed fetch is skipped entirely for such tenants via immediate: !isBlankSite. Deliberately keyed off the server count rather than the resolved feed: both endpoints read through the same enhanced-Prisma policy scope, so totalArticles === 0 means "nothing this viewer may see", which is why an owner holding only drafts keeps the normal homepage (their drafts are policy-visible to them) while anonymous visitors get the empty page.
      • Empty state is audience-split under Article/Empty/Page.vue is the shell (centred full-height section, gradient/dot backdrop, logo or name monogram) and picks one of two bodies by isOwner. Visitor.vue: site name as h1, description, a "coming soon" pill (articles.empty.badge) and articles.empty.title/message. Owner.vue: an "only you can see this" badge, articles.empty.owner.* copy stating that visitors see this page and that it is noindex until the first article, primary/secondary CTAs to admin-editor-id/new (the second with ?ai=1, which the editor reads to autofocus its AI composer — the panel itself is always visible now), plus a launch checklist with a progress bar. isOwner is role === 'superadmin' || (role === 'admin' && user.clientSiteId === clientSite.id), so an admin of another tenant sees the visitor copy.
      • Checklist logic is pure and tested in shared/utils/emptySite.ts (buildEmptySetupSteps + emptySetupProgress, tests/unit/emptySite.test.ts); the component only maps a step id to an icon and a route. Steps are the four things a tenant admin can actually do: article (never done — it is the reason the page exists), branding (logoUrl and descriptionsettings?tab=branding), voice (focus and audiencesettings?tab=content, locked off AI_CAPABLE_PLANS so BASIC sees a lock instead of a link into a tab that is not rendered for it) and domain (domainVerified/admin, where Admin/DomainVerificationBanner.vue lives). Locked steps are excluded from the progress total, so BASIC reads "n of 3".
      • The feed section itself is now guarded by showFeed (pending || hasFilters || filteredArticles.length) and its terminal branch is only articles.noResults.message. This also fixes the single-article case: filteredArticles excludes the featured article, so a one-article site used to render "Nothing here yet" underneath that article's own featured card — now the redundant section (heading + filter bar included) simply does not render.
      • Thin-content noindex. server/api/articles/featured/[slug].ts returns the totalArticles count it already computed, and the page emits robots: 'noindex, follow' until that count is non-zero, so an unpopulated tenant homepage is never indexed as thin content. The count comes from enhanced Prisma, so for an anonymous crawler it is the policy-visible (published) count — exactly the right signal. This is why the featured fetch is a blocking useFetch rather than useLazyFetch: robots meta must be correct in the SSR HTML, and a lazy fetch would render noindex for every site on first paint. The feed below the fold stays lazy — and is not requested at all when the count is zero. The same count now also drives isBlankSite, so noindex and the dedicated empty page always agree.
    • settings/index.vue (admin middleware) — full client settings, migrated from the former Client/Preferences.vue modal into a dedicated page. Sections are tabs driven by ?tab= (branding/content/integrations/ai/billing, gated by plan + tokenLimit + billingPlan). Owns the client fetch + shared form (seeded by the pure app/utils/buildClientSettingsForm.ts), one sticky PATCH /api/clients/:id save, and an onBeforeRouteLeave unsaved-changes guard whose dirty check is a fast-deep-equal of form vs a pristine snapshot (replaces the old hand-maintained field-by-field boolean). Consumes common.preferences.* (incl. new preferences.tabs.*). Opened from Sidebar.vue cog → router.push('/settings').
  • components/ — grouped by domain: Admin/, App/, Article/ (incl. Article/Empty/ — the zero-article homepage, see pages/index.vue), Auth/, Button/, Charts.vue, Client/, Comment/, Dev/, Dropdown/, Emoji/, File/, Form/, Gif/, Landing/, Modal/, Network/, Notification/, OgImage/, Settings/, Stats/, Status/, Tags/, User/, plus the shared Tiptap editor TiptapEditor.vue. The client settings sections live in Form/Client/ (Branding/Content/LinkedIn/AI/Billing) + Settings/Nav.vue (tab rail), composed by pages/settings/index.vue.
  • composables/ — 15 hooks (article SEO/tracking/drafts/actions/events, ads/GAM, currency, profile, image retry, client-site events, theme, dev view override, modal response — useModalResponse powers Modal/Mini.vue's imperative ask() returning Promise<'ok'|'no'> — and useTime, see Time stack below).
  • Time/date stack — single source of truth in shared/utils/time.ts (TIME_PRESETS: date, datetime, short, shortDatetime, time as Intl.DateTimeFormatOptions, plus the relative preset). <AppTime :datetime preset> (App/Time.vue) wraps the built-in <NuxtTime> — SSR-safe, live-ticking relative, locale auto-injected from useI18n — for templates; useTime().formatTime(date, preset, localeOverride?) is the JS-context counterpart (table cell renderers, $t interpolation) using the same presets via Intl. Prefer these over date-fns format(). The smart hybrid relative/absolute timestamp formatDate (shared/utils/index.ts, custom thresholds + articles.dateFormats.* keys) is a deliberately separate concern and still uses date-fns.
  • Credentials login security lives in server/utils/authLogin.ts: email lookup is trimmed, lower-cased and case-insensitive; Better Stack receives structured success/failure/rate-limit events with a keyed identity fingerprint rather than the email; Valkey-backed limits apply independently to IP and identity at both the TOTP preflight and the direct NextAuth authorize boundary, with the cache module's in-process fallback during an outage. Failure reasons remain private while the client receives generic credentials errors. lastLogin is written only by the successful server authorize path, and Auth/Form.vue must reject a returned signIn().error before fetching the profile or navigating.
  • A client-side permission gate must restate the ZenStack rule, not a stricter guess. canManageArticle (shared/utils/articleEditor.ts, tests/unit/articleEditor.test.ts) is admin && user.clientSiteId === article.clientSiteId — the exact @@allow('all', …) on Article, authorship excluded, so a tenant's admins reach each other's articles and the AI author's, which no human owns. Superadmin is out on both sides. pages/clanky/[slug].vue had role === 'admin' && session.user.id === data.user.id inline and hid the edit/status/comments controls the server would have accepted; pages/index.vue's isOwner never had that clause.
  • Data-fetching split (HARD RULE) — two layers, chosen by whether the response is SSR/SEO-critical:
    • useFetch / useAsyncData for public, SSR-rendered, SEO-critical reads (pages/index.vue, clanky/[slug], stitky/[slug], autor/[name], useClientSite.ts). These depend on the Nuxt payload, getCachedData, and blocking-vs-lazy semantics that drive robots meta — do not migrate them.
    • A useFetch payload you write back into needs deep: true. Nuxt 4 defaults deep to false, so data is a shallowRef. pages/clanky/[slug].vue writes likes/shared/followerCount back into the article after each action; a triggerRef re-renders the page but Article/ActionsBar.vue gets the same object identity, so hasPropsChanged bails and the counts only moved on a manual refresh. A triggerRef beside a useFetch payload is therefore a symptom, not a fix.
    • Pinia Colada (useQuery/useMutation) for client-only admin CRUD behind auth. Rationale: those endpoints are per-user and per-tenant (policy-scoped through getEnhancedPrisma), so they can never be cached server-side (ISR / defineCachedEventHandler / server/utils/cache.ts, whose invariant forbids personalised data) — a shared cache there would be a cross-tenant leak. Colada's cache lives in one authenticated user's tab, so isolation is structural.
    • Query keys are centralised in app/utils/queryKeys.ts and are the invalidation contract; the prefix hierarchy (['articles'] ⊃ ['articles','list'] / ['articles','detail',id] ⊃ …tags / …available-tags, plus ['clients'] and ['stats']) is pinned by tests/unit/queryKeys.test.ts, which also asserts the three roots can never invalidate each other. Never inline a key literal at a call site — a wrong key surfaces as stale UI, which is a worse failure than the over-fetching it replaces.
    • SSR + auth: queries must fetch via useRequestFetch(), not bare $fetch — Colada prefetches on the server via onServerPrefetch and bare $fetch does not forward the session cookie (401). Mutations are client-only and use plain $fetch.
    • Global defaults live in colada.options.ts at the repo root, not in nuxt.config.ts — the module declares configKey: 'colada' but reads a root-level file, and silently falls back to export default {} if it is missing. staleTime is 300 s (library default is 5 s, which would refetch on practically every remount and defeat the point of the cache); gcTime 30 min. The module also auto-imports useQuery / useMutation / useQueryCache, so do not import them explicitly.
    • Every query renders three states. A failed fetch must never fall through to the empty state — before this was fixed, a 401/500 left data undefined, rows empty, and the table showed "no articles" for what was actually a broken request. Each migrated component derives loadFailed from error and an absence of data (so a refetch failure over placeholderData keeps the stale rows visible instead of blanking them), and offers refetch().
    • Route-type blowups: typing a query as requestFetch('/api/…') can trigger TS2321 Excessive stack depth — Nitro's route-matching types leak into Colada's generic inference. Fix by passing an explicit generic sourced from InternalApi['/api/route']['default' | 'get'] (nitropack/types), which keeps the real response type without re-resolving the route. For mutations, an async … => { await $fetch(…) } body (concrete Promise<void>) is enough.
    • Cross-component invalidation goes through useCacheInvalidation() (app/composables/), the single place that maps a domain event to the keys it dirties (e.g. creating or deleting an article dirties articles and stats; editing dirties only articles; a tag edit dirties just that article's detail). Call it directly after the mutation — do not reintroduce an event bus.
    • mitt is fully removed (useArticleEvent / useClientEvent deleted, dependency dropped). It was only ever a cache-invalidation mechanism — every handler was a bare refresh() — and it had two structural faults Colada does not: the module-level emitter leaked one never-removed listener per SSR render and per remount, and an event was lost entirely if the interested component happened to be unmounted. Colada marks the cache entry stale instead, so a closed dialog refetches correctly the next time it opens.
    • Two components read the same article tags, so they must share a key: Article/Tag.vue (the modal off the article table) and Tags/Manager.vue (embedded in Article/Modal.vue and the editor) both hit /api/articles/:id/tags and are both keyed queryKeys.articles.tags(id). They previously held two independent caches, so adding a tag in one left the other stale — and Article/Modal.vue's invalidateArticleDetail() only reached half its own subtree. Tags/Manager.vue keeps its local tagBuffer staging model (it doubles as the create-mode tag picker, where no article exists yet and the parent persists on save); the query only seeds it, gated by enabled: () => !!props.article?.id.
    • Creating a tag dirties two roots — the article's detail and ['tags'], the tenant tag catalogue behind /api/tags shared by Tags/Manager.vue and Tags/Create.vue. Article/Tag.vue's create mutation invalidates both.
    • Migrated: Article/Table.vue and Client/Table.vue (keyed pagination, placeholderData, debounced search feeding the key, refetch dimming + aria-busy; the article table also has row skeletons), Article/Tag.vue, Tags/Manager.vue, Stats/Dialog.vue (dashboard + sentiment, the latter gated by enabled: () => !isBasicPlan). Mutation sites (Article/Modal.vue, Client/Create.vue, pages/admin/editor/[id].vue) invalidate rather than emit. Still on useFetch: User/List.vue, Tags/Create.vue, Client/Users.vue, Client/Version.vue, User/Activity.vue, Notification/Bar.vue (the last is a hand-rolled infinite scroll — useInfiniteQuery + refetchInterval candidate).
  • Connection state is a state, not a toastNetwork/Indicator.vue (mounted once in app.vue) keeps its pill up for as long as the connection is down and only auto-dismisses the recovery, which reuses the same mounted pill so red→green is a color transition rather than two separate notifications. The visibility machine is composables/useNetworkPill.ts (tests/unit/networkIndicator.test.ts); the component only maps state to an icon and a surface. A single shared timeout meant a drop landing mid-recovery inherited the pending dismiss and hid itself after 3 s while still offline.
  • stores/ — Pinia (theme.ts); persistence handled per-store via plugin.
  • layouts/, middleware/, error.vue, app.vue — standard Nuxt scaffolding. The global Header.vue is a fixed h-18 overlay: content-heavy public pages own their hero spacing, while app pages (admin, master, settings) reserve the header plus their intended gap explicitly; Settings navigation sticks at top-24, and the editor header at top-18. Making the global header participate in flex layout duplicates the homepage's existing clearance and makes descendant sticky offsets slide behind it. One other load-bearing detail in layouts/default.vue: the root wrapper is overflow-x-clip, not overflow-hidden. hidden makes that box a scroll container, and position: sticky resolves against its nearest scrollport — a box that is exactly as tall as its content and therefore never scrolls, so every sticky descendant silently degrades to static while the page scrolls past it. That killed the article editor's sticky header (taking Save/Publish off screen the moment you scrolled into the body), the Tiptap toolbar, the homepage filter bar and Footer.vue — all four had been written as sticky and none of them stuck. clip still contains horizontal bleed, which is the reason the class is there next to max-w-screen, without establishing a scrollport (and overflow-y stays visible beside clip, unlike beside hidden). Don't swap it back.
  • assets/styles/ — global SCSS (entry base.scss, loaded via nuxt.config.css). App-surface element rules (div/button/text/forms/[role=dialog]) are scoped under :where(#__nuxt, #headlessui-portal-root) so they cannot bleed into body-teleported overlays (DevConsole, BackToTop); :where() keeps specificity neutral, so in-app rendering is unchanged. Third-party widget overrides (iziToast, tippy, YouTube) live isolated in _vendor.scss and stay global because those libs portal into <body>. Theme tokens in _variables.scss.
    • The transparent-dialog rule is HeadlessUI-specific, not a role selector. background-color: transparent !important exists because HeadlessUI's Dialog root is a full-screen wrapper whose visible surface is the DialogPanel inside it. Written as a bare [role='dialog'], that !important also stripped the background off every hand-rolled popover carrying the same ARIA role — Article/Editor/Popover.vue, i.e. the editor's tag picker and release-date picker, which rendered as floating text over the article. It is now keyed on [role='dialog'][id^='headlessui-dialog-'], the id HeadlessUI generates on the root and nowhere else (DialogPanel gets headlessui-dialog-panel-* and no role, so the companion panel rules still match). tests/unit/globalStyles.test.ts fails if the scope is ever widened again.

4. Server Layer (server/)

  • api/ — route handlers grouped by resource: admin, articles (CRUD, search, drafts, featured, generate, by-clientsite), auth, bans, clients, comments, companies, crons, currency, drafts, emojis, external, features, follows, gifs, linkedin, notifications, onboarding, publish, series, sessions, stats, stripe, tags, users, plus standalone upload.ts.
  • tasks/ — Nitro scheduled tasks. All wrapped via defineMonitoredTask (utils/monitoring.ts) so each run pings its Better Stack heartbeat (BETTERSTACK_HEARTBEAT_<NAME>) on success/fail — covers both the Nitro/Vercel-cron path and manual runTask() cron routes; no-ops when the env var is unset.
  • utils/ — Cross-cutting helpers: prisma.ts, zenstack.ts, session.ts, requireUser.ts (auto-imported per-route auth guard — resolves getServerSession, enforces optional role/clientSite, throws localized 401/403; the single audited chokepoint for access control, replacing the copy-pasted session-check boilerplate across handlers), sendEmail.ts, stripe.ts (Stripe SDK singleton — useStripe(), see §7), sanitize.ts, geo.ts, ip.ts, metrics.ts, paginator.ts, log.ts, userLog.ts, consumeTokens.ts, tokenRatio.ts, unsplash.ts, pdfFont.ts, i18n.ts, notificationsPoll.ts (pure cursor/query helpers for the notifications poll endpoint), monitoring.ts (Better Stack heartbeats: pingHeartbeat/withHeartbeat/defineMonitoredTask), plus ai/ and linkedin/ subdirs. linkedin/publisher.ts publishes drafts to LinkedIn behind an atomic claim (executePublish flips DraftStatusPUBLISHING via a guarded updateMany, so concurrent cron runs / manual triggers can never double-post; PublishedPost.draftId unique is the DB backstop). publishApprovedDraft is the cron/manual entry for human-approved drafts; publishDecisionAndExecute is the post-generation auto-publish-or-review decision. linkedin/token.ts (getValidAccessToken) proactively refreshes the LinkedIn access token via the stored refresh token (5-min expiry buffer) and persists it before publish/metrics calls — used by publisher.ts and the linkedin-sync cron (which caches the token per company for the run). linkedin/oauthState.ts (signOAuthState/verifyOAuthState) HMAC-signs the OAuth state with AUTH_SECRET; the connectcallback flow is session-guarded (admin/superadmin only), derives clientSiteId from the session (never the query), binds the signed state to an httpOnly CSRF cookie, and re-checks clientSiteId ownership on callback before writing tokens via enhanced Prisma — closing the previous unauthenticated-IDOR hole.

Local DevConsole

  • components/Dev/Console.vue — a draggable, dev-only floating panel (Teleport to body, useDraggable + useLocalStorage for persisted position/collapse). Rendered in app.vue via Nuxt's <DevOnly>, so it is stripped from production builds. Complements (does not replace) Nuxt DevTools.
  • Capabilities: force the rendered view (auto/tenant) so tenant resolution can be exercised on localhost; impersonate seed users (reader/admin/super) via the real signIn('credentials') flow with seed creds; show git branch/short-hash/dirty flag and the resolved tenant + plan. Toggle/hide via Ctrl+Shift+D (a corner launcher restores it); drag snaps to the nearest edge and clamps to the viewport.
  • View override lives in composables/useDevView.ts (cookie-backed for SSR consistency). Git context comes from server/api/_dev/meta.get.ts, a dev-only endpoint (404s in production).
  • Host topology (two separate projects, both on the Dokploy VPS since 2026-08):
    • Apex topiqu.com (+ www) → separate landing project (marketing), deployed independently of this app. The old in-app 302 to landing.topiqu.com was removed; the app no longer routes anyone to a landing subdomain.
    • app.topiqu.com + *.topiqu.com catch-all → this app project.
    • Wildcard TLS for *.topiqu.com is managed by Dokploy itself (its Traefik certificate handling), not by a Cloudflare Origin CA cert or a hand-edited traefik.yml resolver. Consequence worth knowing: that certificate is platform state living outside this repo — nothing here reproduces it, so a Dokploy rebuild/restore has to restore it too, and it is not covered by any repo-level backup.
  • Auth / OAuth: OAuth completes on app.topiqu.com (Google/GitHub authorized redirect URIs include https://app.topiqu.com/api/auth/callback/*; the apex topiqu.com URI also stays registered). authjs runs with trustHost (no pinned AUTH_ORIGIN) so the redirect_uri is derived from the request host. Auth/Form.vue#handleSocialAuth: on app.topiqu.com it calls signIn(provider) directly; on any other host (tenant subdomain) it hops to app.topiqu.com/oauth-start, which runs the signin POST so the callback lands on the registered host. The session cookie is set on .topiqu.com, so login is shared across the apex and every *.topiqu.com subdomain (the redirect callback whitelists *.topiqu.com).
  • App surfaces (app.vue): app.topiqu.com is treated as a root domain (no tenant lookup; root redirects to /autorizace). The marketing site is a separate project (../landing, served on the apex) — this app renders no landing surface of its own; the former in-app Landing/ tree was removed as a duplicate of it.
  • Dev resolver note: server/api/clients/slug/[slug].ts matches { OR: [domain, name] } outside production so localhost (seeded ClientSite.domain = localhost) resolves to a tenant; production still matches domain only. It returns publicClientSiteSelect only — see Who may read which ClientSite field — so useClientSite() is typed PublicClientSite, and anything privileged has to come from useClientSiteStatus().
  • Admin surfaces are bound to their own tenant's host (middleware/admin.ts, covering admin, admin/editor/[id], settings). The session cookie is .topiqu.com-wide (server/utils/sessionGuard.ts), so before the bind an admin browsing another tenant's blog got an admin page whose host half (useClientSite(): plan, ids, domain) described that tenant while every mutation hit their own — the upgrade banner advertised the foreign tenant's plan and charged the session's. isForeignHost (shared/utils/domain.ts) compares the two ids; on a mismatch the middleware re-routes to the same path on the session tenant's own verified domain, falling back to / when that domain is unverified or resolves back to the current host (which would loop). Root hosts carry no tenant, so they never trip it. Corollary: inside /admin/** and /settings, useClientSite() is the session tenant — but anything that gates or spends against a plan should still read auth.user.plan, which the JWT callback re-reads from the DB on every request.

Notifications delivery

  • Notifications are persisted in the DB (source of truth) and delivered to the client by polling, not push. The client (Notification/Bar.vue) polls GET /api/notifications/poll?since=<ISO cursor> every 10s (paused while the tab is hidden) and merges anything newer.
  • Article-like notifications include the related article's imageUrl; Notification/Bar.vue renders it as a compact, linked cover thumbnail when one exists. Both the paginated endpoint and polling select the same article fields so realtime and initially loaded notifications have identical UI data.
  • Article/Series.vue uses the cover URLs returned in the article detail's series payload: compact thumbnails identify every entry in the expandable list, while the previous/next cards show wider covers and a two-line excerpt.
  • The article editor's leave guard compares articleEditorSnapshot values containing only persisted form inputs, tags and the selected series. API-derived fields such as counters and expanded relations cannot create a false dirty state after save or publish.
  • This replaced an in-memory SSE channel (server/utils/realtime.ts + /api/notifications/sse + the useRealtime composable), which is unviable on serverless/Vercel: held connections incur per-request/wall-clock billing and isolated function memory means a publish in one function never reaches a subscriber in another. That whole layer was removed; reviving push would mean a managed WebSocket provider (Ably/Pusher) or a long-running process off Vercel, not a serverless patch. Cursor semantics live in server/utils/notificationsPoll.ts and are unit-tested.
  • That constraint is gone — the app now runs as a persistent process on its own box (§1), so SSE is viable again and the removed layer is the reference design for bringing it back. Polling is simply what still ships. One caveat before it returns: in-process pub/sub only works at one replica; more than one needs fan-out through Redis/Valkey, which ties into the same "1 vs N replicas" decision as the crons (§8).

Blog statistics (/api/stats/dashboard)

  • Saved time / money are derived on read. One formula in shared/utils/savings.ts → writingSavings (tests/unit/savings.test.ts): words ÷ speed = hours, hours × rate = money. The dashboard sums totalWords over aiInvolvement: 'FULL' only (the editor demotes to ASSIST on the first human edit) and prices it with the tenant's current rate. Article.savedAmount / savedTimeMinutes are still written but no longer authoritative — they froze a rate at generation time, so a correction never reached existing articles. Drop them in a later migration; don't add readers.
  • clients/[id] PATCH gets linkedinMode on every save, because the settings form defaults it — so that branch runs for tenants who never connected LinkedIn. It must not create a LinkedinCompany: only the OAuth callback has a real linkedinOrgId, and the stub value it used instead collided on the unique index, 500-ing settings for every tenant after the first.
  • The rate is USD and the column name says so. humanHourlyRate held a CZK figure while the UI formatted it as the tenant's currency (default USD), inflating the number ~23×. Migration 20260808120000_human_rate_usd renames it to humanHourlyRateUsd; display multiplies by useCurrencyRate like Billing.vue / AI.vue. The default (35) is stated twice — client.zmodel and DEFAULT_HOURLY_RATE_USD — so changing it takes both plus a migration that moves rows off the old value, or tenants stay priced at it forever.
  • Stats/Dialog.vue is a report, not a card grid. Sections (Stats/SectionHeading.vue) over hairline rules, one repeated row (Stats/Row.vue — rank/icon, truncating label, right-aligned metric, optional share bar) for both the content highlights and the tag list. Colour is semantic and scarce: emerald = money/AI-written, indigo = interactive, everything else neutral. The previous version was 11 identical cards in 10 accent colours, each with hover:scale, which made nothing look important and implied every card was clickable. Charts is behind <LazyCharts> so chart.js stays out of the chunk for BASIC, which never renders it.
  • Truncation needs min-w-0. A flex item defaults to min-width: auto and refuses to shrink below its content, so a long tag name pushed the view count out of its container. Every truncating label in this modal sits in a min-w-0 parent with a shrink-0 metric beside it.
  • Modal/index.vue had no dark tokens at all — a white panel in dark mode, with the one card that did carry dark: variants floating in it. The panel and its title gradients now have them. h-11/12 is untouched and still forces every modal to ~92% viewport height regardless of content.
  • Charts.vue takes semantics, not a Chart.js configkind (trend bar↔line / breakdown bar↔pie), labels, values; it owns colours and scales. It used to branch on title === 'Rozložení sdílení podle platformy', so every chart picked the wrong type in English. Colour is keyed to the entity, not its rank — hence Stats/Dialog.vue always sends all five share platforms including zeros, since dropping the empty ones would repaint the survivors.
  • A share is gated per identity per platform, the same way ArticleReaction gates a like: ArticleShare carries userId/sessionId (the client fingerprint for anonymous visitors) and the handler skips the increment when a row already exists. The unique index is only a backstop — every row carries a NULL in the tuple, and Postgres treats NULLs as distinct, so it catches nothing on its own.
  • ArticleView is the view-event log (migration 20260819120000_article_view_events); Article.views stays the running counter, so sorting and totals keep their fast path. One row per visitor per article per UTC day, and sessionId is non-nullable precisely so the unique index bites — ArticleShare's equivalent index catches nothing because every row carries a NULL in the tuple. view.post.ts inserts with ON CONFLICT DO NOTHING and increments the counter only when a row was actually written, so the two never drift. Consequences: the counter now means unique visitor-days, not raw hits, so it grows slower than the historical values above it; the series cannot be backfilled, hence trackingSince (NULL until the first event) which the client renders as "měřeno od …" instead of drawing flat zeros.
  • The daily series is real readership now, bucketed on ArticleView.viewedOn over VIEW_TREND_DAYS (30). It replaced a 7-day series bucketed by COALESCE(publishedAt, createdAt), which counted views accumulated by articles published that day — not views that happened that day.
  • Views were forgeable. view.post.ts had no session, no dedup, no rate limit and no status filter: a loop of curl moved the number, and an admin previewing a draft inflated it. The identity is now server-issued (the anon_session cookie, never read from the body) and drafts are rejected. Totals and topArticle filter status: 'published' for the same reason.
  • Engagement is a site-wide ratio, interactions ÷ views (server/utils/dashboardStats.ts, tests/unit/dashboardStats.test.ts). It used to be the mean of per-article ratios, which let a 1-view article with 3 comments contribute 300% — that is why the client clamped the display at 100%. The clamp is gone; do not reintroduce it to "fix" a number above 100%, fix the formula.
  • topTags ranks in SQL. Prisma cannot order a tag by an aggregate over its articles, so the old code took the 10 tags with the most articles and sorted those by views — a tag riding two heavily-read articles never entered the candidate set. The raw query joins and ORDER BY SUM(views). Note the metric double-counts by design: an article with three tags gives its views to all three.

OAuth connect flows cross a host boundary

Every provider connect (LinkedIn, Search Console) starts on the tenant's own hostmiddleware/admin.ts pushes admins there — and comes back to APP_URL / AUTH_ORIGIN, i.e. app.topiqu.com. Two consequences, both of which silently broke the flows:

  • State cookies must be scoped, not host-only. server/utils/oauthStateCookie.ts (setOAuthState/takeOAuthState) stamps them with sessionCookieDomain from sessionGuard.ts — the session survives the hop, so the CSRF token has to as well. A host-only cookie never reaches the callback, which then rejects every connect as a forged state. Custom-domain tenants are out of reach for the same reason the session is; they cannot open settings either.
  • Callback redirects must be absolute and locale-prefixed. strategy: 'prefix' leaves no unprefixed route, so a relative /settings is a 404 on the wrong site. The locale rides inside the signed state (whitelisted to cs/en on the way out) because i18n_lang is host-only; the tenant host comes from ClientSite.domain. stripe/*.ts still builds unprefixed /settings?tab=billing return URLs and has the same 404.
  • Each rejection branch logs its own reason — a missing cookie, a mismatch and a bad signature are different failures and used to share one opaque message.

LinkedIn Company Pages — DISABLED (personal-only connect, retained implementation)

  • linkedin/connect.get.ts 403s appType=pages and hardcodes the member scopes (openid profile email w_member_social). The organization scopes it used to request — w_organization_social, r_organization_social, rw_organization_admin — belong to LinkedIn's Community Management API, which LinkedIn grants only to registered legal entities (not sole traders), so the flow could never have completed. tests/server/linkedin/connectEndpoint.test.ts fails if an organization scope reappears.
  • Everything downstream of the guard is intact: LinkedinCompany.type still carries 'pages' | 'personal', token.ts still reads LINKEDIN_CLIENT_ID_COMPANY, and the callback keeps its getPagesUrn branch — unreachable, since appType rides in an HMAC-signed state only connect can mint. Re-enabling is reverting the guard plus the Connect Page button in Form/Client/LinkedIn.vue; do not delete the pages plumbing to "clean up".
  • clients/[id] PATCH no longer prefers a pages row when applying linkedinMode — it takes the tenant's single LinkedinCompany regardless of type, so a legacy pages row stays editable.

5. Shared (shared/)

  • zod/ — split into common/, enums/, input/, models/, objects/ with a barrel index.ts. Schemas reused by both client forms and server validation.
  • types/ — hand-written cross-cut TS types. article.ts → ArticleCardData is the row shape consumed by Article/Collection.vue (shared by the stitky and autor listing pages).
  • utils/ — pure helpers shared by app + server. savings.ts is the single "what would a human have charged for these words" formula — see Blog statistics in §4.

app/utils/ also holds queryKeys.ts (Pinia Colada key factory, see §3 Data-fetching split) alongside buildClientSettingsForm.ts.

  • z-layers.ts — single source of truth for stacking order (Z_LAYERS): header 100 → overlay 1000 (modals/slide-overs/sidebar/fixed chrome) → devtools 5000 → popover 9000 (dropdowns/selects/pickers) → top 9500 (global loading bar). Fed into uno.config.ts theme.zIndex as z-<name> utilities; the raw numbers are imported where a numeric prop is needed (Form/Select.vue<Float :zIndex>). Always layer via these tokens, never a fresh z-[…].

External API

docs/external-api.md is the consumer-facing spec — auth, envelope, per-endpoint field tables, error table, enums, and the pagination/deletion/HTML-rendering recipes an integrator needs. This section stays the architectural note; that file is what a third party reads. A change to any server/api/external/* response shape has to land in both.

  • Every external route authenticates through server/utils/externalApi.ts → requireExternalClient using the tenant's x-api-key; soft-deleted sites are refused and authenticated responses are private, no-store. An admin generates or rotates the single key through POST /api/clients/[id]/api-key; rotation immediately invalidates the previous key. The key is stored on ClientSite and is visible to the owning tenant in Settings → Integrations.
  • GET /api/external/articles remains the backwards-compatible collection endpoint. It returns source-language, published Article rows only, newest first. Its original fields and nested tags[].tag shape remain intact; additive fields now expose updatedAt, publishedAt, reading time/word count, sources, cover credit, series and metadata for published translations. Pagination is page + limit (defaults 1/10, maximum 100), and response metadata includes the primary language and applied tag filters. Optional comma-separated tag filtering is trimmed/deduplicated and retains AND semantics: an article must carry every requested tag slug.
  • GET /api/external/articles/:id returns one published tenant article with the complete safe external projection. Its response uses flat tags, identifies the source language, and exposes published translation summaries as availableTranslations; a foreign, draft, archived or missing id is the same 404.
  • GET /api/external/tags is discovery for filtering: only tags actually attached to at least one published article of the authenticated tenant, sorted by name, with articleCount. This deliberately avoids leaking global/other-tenant tags and omits empty tags.
  • GET /api/external/site returns public integration metadata (name, domain, description, logo, theme, primary/available languages, socials) plus the published article count; it never projects billing, AI configuration or credentials.
  • This remains a read-only pull API. There is no change/deletion cursor, conditional request support, webhook, localized full article response, canonical public URL, key scopes, per-integration keys, rate-limit contract, OpenAPI document or URL version namespace. Those require a deliberate v1 contract and, for reliable unpublish/delete propagation, a persisted change log rather than inference from the current published collection.

WordPress integration (wordpress/topiqu-sync)

  • The installable Topiqu Sync plugin consumes the existing authenticated external API and stores source-language articles as native WordPress post rows. Remote identity and synchronization state live in protected _topiqu_* post meta; tags use WordPress terms and cover images are sideloaded into the Media Library.
  • Synchronization walks the complete paginated published collection in batches of 100. Only after every page succeeds without article-level errors does it reconcile missing remote IDs by moving their imported WordPress posts to draft. It never deletes posts or media, and a partial/failed API scan never unpublishes anything.
  • Update policy is tenant-configurable: safe hashes the last imported title/slug/excerpt/body and preserves a locally edited post, overwrite makes Topiqu authoritative, and new_only imports once. _topiqu_updated_at avoids unchanged writes.
  • Settings, connection testing, manual runs and the last-run summary are under Settings → Topiqu Sync. Automated runs use a locked WP-Cron event at 15-minute, hourly or daily intervals; production sites can invoke that event from a real system cron. API credentials are stored as a non-autoloaded WordPress option and are never written to the sync log.
  • Uninstall removes plugin options, its transient lock and schedule but deliberately retains imported content. Packaging output is wordpress/dist/topiqu-sync.zip and is generated from wordpress/topiqu-sync/.
  • The settings integration catalog uses minimum-plan lanes (Pro and Premium) with explicit included/locked states derived from the tenant's current plan. It defaults to “available to me” and supports text and plan filtering; locked lanes remain discoverable through the other filters but cannot open their settings. Service cards use their brand colors. Google Ad Manager is its own Pro-lane card and dialog; as a field appended to the Google Analytics dialog it was unreachable by the catalog filter, which matches only card title + description — its description therefore names both “Google Ad Manager” and “GAM”. WordPress retains its product description but carries a TBD status with setup instructions hidden until release; the catalog also exposes the existing Google Search Console OAuth connection. Regenerating an existing API key requires destructive-action confirmation because the old key is revoked immediately.
  • by-clientsite/[slug] and featured/[slug] may carry likedByUser only because their 10-min shared cache is reached without a session (tests/server/articles/feedPresentation.test.ts pins that condition).
  • One anonymous identity: server/utils/anonSession.ts. Writes issue the anon_session cookie, GETs only read it — a minting GET hands one to every crawler. Reactions must never take it from the body as f4ece21 did with a FingerprintJS visitorId: the caller picks that value, so it farms freely and never matches what reads resolve. Abuse budget is consumeRateLimit on ipKey(event) in reaction.post.ts, create path only.
  • Client/PreferencesGuide.vue renders the live prompt block from server/utils/ai/topic.ts, so an empty brief field shows the server fallback instead of prose about it. Its per-field message keys are built at runtime, past i18nCompleteness; tests/unit/preferencesGuide.test.ts covers them.
  • Unsaved changes are communicated by app/components/UnsavedBar.vue — one body-teleported floating bar shared by /settings and /uzivatel; settings navigation does not add a second route-leave confirmation. Shared destructive confirmations use Nuxt UI's portaled, scrollable modal overlay so viewport centering comes from the overlay grid rather than manual coordinates.

6. Data Model

  • Authored in ZenStack, split by domain: a thin root prisma/schema.zmodel (generator/datasource/plugins + imports) pulls in prisma/models/*.zmodel (base, article, poll, client, user, comment, notification, linkedin, log). Enums colocate with their domain; cross-domain enums + abstract Base/Ownable live in base.zmodel. Generates schema.prisma. Imports must be at the top of each file; cross-file relations need explicit imports (ZenStack resolves symbols only over a file's transitive imports).
  • Migrations in prisma/migrations/.
  • bun build pipeline: zenstack generateprisma migrate deploynuxt build.

Polls

  • ArticleView is the only event table with a non-nullable identity column (sessionId). That is deliberate: ArticleShare, ArticleReaction and ArticleFeedback all put nullable userId/sessionId in their @@unique, and Postgres treats NULLs as distinct, so those indexes never fire and the handlers carry the dedup alone. Copy ArticleView's shape, not theirs.
  • Fully normalized: Poll (question, order) → PollOption (label, order) → PollResult (vote). All cascade-delete from Article. PollResult references both Poll and the chosen PollOption by real FK (+ articleId kept denormalized so engagement stats use Article._count.pollResults). Two @@unique constraints — (pollId, userId) and (pollId, sessionId) — enforce one vote per identity at the DB layer.
  • Poll blocks are authored as Tiptap nodes (extensions/poll.ts + extensions/Poll.vue) and serialized into Article.content as <div data-type="poll" data-poll-id data-question data-options>, where data-options is a JSON array of { id, label }.
  • On article create/edit, server/utils/articlePolls.ts → syncArticlePolls reconciles the embedded blocks with the Poll/PollOption rows and stamps each block with server-assigned ids (poll id on the block, option ids inside data-options). Question/labels stay mirrored in the HTML so the editor round-trips and client rendering needs no DB read; the DB holds the stable ids votes key off. Option/label normalization lives in shared/utils/polls.ts (normalizePollOptions).
  • syncArticlePolls is mandatory on every write path that persists Article.content — it is the only code that creates Poll/PollOption rows and the only code that mints the ids the vote endpoints key off, so skipping it yields a poll that is unvotable in both directions at once. finalizeArticle emits only a cosmetic data-id and label-only options, so the generate-article cron (which writes AI output straight to the row) must run the same sync + sanitizeHtml as articles/index.post.ts and [id]/index.patch.ts; it previously did neither, shipping polls whose every click was a silent no-op (Poll.vue bails before $fetch on a missing option id, and the GET counts endpoint happily returns an empty tally for an unknown pollId, so the widget looked live). scripts/backfill-polls.ts repairs rows written before the fix (dry-run by default, APPLY=1 to write). Covered by tests/server/articles/articlePolls.test.ts.
  • The editor rewrites data-options on every keystroke (Tiptap/Editor.vue → validateContent, fed by onChange), so that pass must round-trip the option ids and be idempotent. It went through shared/utils/polls.ts → pollOptionsAttr (tests/unit/polls.test.ts) instead of an inline String(x) that assumed the legacy string[] shape — that wrote [object Object] over every label and dropped the ids, which Poll.vue then rendered as unvotable buttons at 0%. It hit any poll opened in an editor, most visibly the translation reviewer, since a translated article is edited there far more often than the source is re-saved.
  • Votes key off optionId (not the label text), so renaming an option never splits counts and removing one cleans up its votes via cascade. Vote endpoints: server/api/articles/[id]/vote.ts (GET counts via groupBy) + vote.post.ts (cast; relies on the unique constraint → P2002 → 409).
  • Render: Article/Parsed.vue (client-side parse) → Article/Poll.vue (votes by optionId); homepage "latest poll" via extractPollData in by-clientsite/[slug].ts. Both require data-poll-id and do not fall back to data-id: that attribute is cosmetic, never a vote target, and the fallback's only effect was to render a dead widget instead of surfacing the missing sync.
  • Legacy note: the 20260527130000_polls_normalized migration wipes any pre-existing PollResult rows (old text-based votes couldn't be remapped to option ids without parsing HTML) — a one-time, intentional reset.

Article Translations

Automated AI translations turn the mono-lingual Article into a multi-lingual one via an ArticleTranslation sidecar (1:N from Article, cascade; denormalized clientSiteId). The source Article stays the canonical carrier of the primary language + all language-neutral data (views, comments, reactions, polls-as-entities, series); translations are pure per-language renditions. Migration 20260604120000_article_translations.

  • Lifecycle as queue. status: PENDING → TRANSLATING → READY → PUBLISHED (+ STALE on source edit, FAILED). Body fields (slug/title/content) are nullable — a queued row is a translation request without a body until the cron fills it. @@unique([articleId, language]) (one row per language) + @@unique([slug, clientSiteId, language]) (localized slug namespace, separate from the source's @@unique([slug, clientSiteId]) — locale-scoped routing means they never collide).
  • Config (ClientSite). translationMode (OFF/MANUAL/AUTO/HYBRID) + translationLanguages[] (empty = all supported langs except primary). Set in the AI preferences form (Form/Client/AI.vuecommon.preferences.translation.*). Gated on the active AI ClientFeature + plan PRO/PREMIUM/CUSTOM (see §6b). The "empty means all" rule lives in one place — translationQueue.ts → resolveTargetLanguages, shared by the enqueue cron and the review panel's endpoint, tested in tests/server/ai/translationQueue.test.ts; read as "none" it would silently disable translation for every tenant that never picked languages.
  • Engine (deterministic). server/utils/ai/translate.ts → generateTranslation uses aiModel('translation') (GPT-5.6 Luna — this is the most output-token-heavy call in the app at up to 20k, so it stays on the cheapest capable tier; Luna's 1M context also leaves headroom on BATCH_SIZE in server/tasks/translate-pending.ts). Poll/embed/img blocks are masked out with cheerio before the model sees them (maskContentBlocks) — data-poll-id/optionId/image src never travel through the LLM as free text; poll question+labels, image alt/title, and human-readable attributes on surviving elements (title/aria-label, e.g. link tooltips — replaced in-place with [[ATTR_n]] tokens) are translated as ordered structured fields and zipped back onto server-held markup on rebuildContent (empty/absent alt stays untouched → decorative images preserved; attribute values are HTML-escaped on re-injection so a translated "/& can't break out of the attribute). Twitter embeds restored verbatim. Pure of billing — caller charges via consumeClientTokens('TRANSLATE_ARTICLE') (always; CUSTOM's unlimited bundle makes it effectively free without special-casing).
  • Two trigger paths. (1) On-demand POST /api/articles/[id]/translate (MANUAL/HYBRID); (2) cron server/tasks/translate-pending.ts (every 5 min) drains PENDING/STALE with a per-row atomic claim (guarded updateManyTRANSLATING) so concurrent runs never double-translate — AUTO → PUBLISHED, HYBRID → READY (awaiting review), out-of-budget rows release back to PENDING. Enqueue/STALE is wired into all four publish paths via server/utils/ai/translationQueue.ts → syncArticleTranslationQueue (articles/index.post, [id]/index.patch, publish-check, generate-article). STALE keys off an explicit content-change signal, not Article.updatedAt (bumped by the view counter), to avoid token churn.
  • Shared slug dedupe. server/utils/ai/translationSlug.ts → dedupeTranslationSlug (used by both endpoint and cron). SEO rendering: see §8.
  • Every endpoint the article page calls must resolve the slug the same way (server/utils/articleBySlug.ts → resolveArticleBySlug, tests/server/articles/articleBySlug.test.ts). On a non-primary locale the URL carries the ArticleTranslation slug, which does not exist on Article at all. [id]/index.get.ts handled that; [id]/related never did, and its findUniqueOrThrow turned it into an unhandled 500 on every /en article view — latent until publishing a translation plus the mismatched-locale redirect made translated slugs actually reachable. The resolver is now shared: translation first (PUBLISHED, or any status for an admin), source row as fallback for an untranslated locale, null → 404 rather than P2025. related also localizes its cards via localizeArticles and is scoped to clientSiteIdTag.clientSiteId is nullable, so a global tag matched articles across tenants.
  • Review UI — HYBRID's missing half. READY means "awaiting review", but until now nothing in app/ could review it: no page read ArticleTranslation, and no client called POST /api/articles/[id]/translate. HYBRID therefore burned tokens writing rows no one could publish, and MANUAL was unreachable. The reviewer is Article/Translations.vue, mounted in admin/editor/[id].vue for saved articles only — a language tab per target, status/source/timestamp, editable title+excerpt+Tiptap body, and Approve & publish / Translate now / Translate again / Discard. Admin/TranslationReviewBanner.vue surfaces the queue on /admin (top 5 READY rows, self-hiding when empty, links straight into the editor).
    • Endpoints: GET /api/articles/[id]/translations (rows + targetLanguages + mode), GET /api/translations?status= (tenant queue), PATCH /api/translations/[id] (edit / approve), DELETE /api/translations/[id] (discard).
    • These endpoints pin clientSiteId explicitly on top of the ZenStack policy. ArticleTranslation's read rule is status == 'PUBLISHED' || (admin && own site) — the first clause is deliberately unconditional so public article pages can render translations, which means a bare policy-scoped query would have let one tenant's admin list other tenants' published translations. The policy is right for the public path and wrong as the only isolation for an admin listing; both layers apply here.
    • A human edit flips source to HUMAN — the audit column must stop claiming a machine wrote text a person rewrote. PUBLISHED is refused unless title+content exist, so a body-less queue row (PENDING/TRANSLATING/FAILED) can't be published into a blank localized page with a hreflang pointing at it.
    • Language is a dimension of the article, not an appendix. The reviewer used to be Article/Translations.vue, a panel at the very bottom of the editor — below a Tiptap body of unbounded height, so nothing said it existed. That component is gone. admin/editor/[id].vue now carries Article/Editor/LanguageTabs.vue (a segmented control in the sticky header, stacked below it under sm): the source tab plus one tab per target language with a status dot. Selecting a language swaps the same title/excerpt/body fields over to that translation, and the header's Save/Publish become Save / Approve & publish acting on it. Language-neutral surfaces — AI composer, cover uploader, MetaBar, drafts — render only on the source tab, because they belong to the article rather than to one rendition of it; the translation tab shows a status strip (source, timestamp, unsaved marker) with Translate again / Discard instead.
    • app/composables/useArticleTranslations.ts owns that state: activeLang === '' means the source, so the page branches on one flag instead of tracking two ideas of "current language". It is reactive()-wrapped at the call site so the template reads tr.isSource rather than tr.isSource.value. The page routes the shared textareas through titleModel/excerptModel/bodyModel computeds, so useTextareaAutosize stays bound to one thing while the underlying target changes. hasChanges folds in tr.isDirty, or leaving the editor would silently drop a rewritten translation.
    • Pure logic lives in shared/utils/articleTranslations.ts (tests/unit/articleTranslations.test.ts): translationHasBody (the condition Save renders on), isTranslationDirty (the condition it is enabled on), countAwaitingReview, resolveActiveLanguage, and the translationStatusDot/translationStatusBadge class maps. resolveActiveLanguage also fixes a latent bug in the old inline watchEffect — it picked the first READY row without checking it is still a configured target, so a leftover translation for a language dropped from settings selected a tab that is not rendered.
    • Listings are locale-aware (shared/utils/articleLocale.ts + server/utils/articleLocale.ts, tests/unit/articleLocale.test.ts). Until now neither by-clientsite/[slug].ts nor featured/[slug].ts touched ArticleTranslation at all, so an /en visitor got Czech titles linking to Czech slugs and a published translation was reachable only via hreflang or Google — tokens were being spent on pages nobody could click to. Both endpoints now take locale and overlay each card with its PUBLISHED translation. overlayTranslation only swaps when the row has both slug and title, since localized text pointing at the primary-language URL is worse than no translation; the excerpt may fall back. Two things to keep in mind: the feed's Redis key carries :loc= (the cached payload is localized, so two locales must not share an entry), and localizeArticles filters status: 'PUBLISHED' explicitly rather than trusting the ZenStack policy, whose read rule also passes for an admin on their own site — otherwise a logged-in admin would see unreviewed drafts in the public feed.
    • Navigating between language versions is one component, Article/LanguageLinks.vue, on three surfaces. target="public" renders localized NuxtLinks to each version's own slug; target="editor" points every entry at the source article (articleRef) with ?lang= picking the tab, because the editor resolves the article by its source slug and the translated slug would 404 there. Dots (translationStatusDot) render only for the editor target — on the public page every entry is published, so a dot would encode nothing.
      • Article detail — gated on hasTranslations (alternates.length >= 2), the same condition as hreflang, so a language is never offered that would only fall back.
      • Admin Article/Table.vue — a languages column, not extra rows: the bloat guard from §6b holds. articles/search.ts gained include: { translations: { select: { language, status, slug } } }, and drafts are visible here on purpose (it is the owning admin's own list, and "awaiting review" is the point). Only languages with an existing row are listed — a configured-but-never-translated language would be a placeholder on every row. The actions column includes an explicit eye action linking to the public article on both desktop and mobile; title and thumbnail remain links as larger hit targets. On the article detail, ArticleStatusCell is the single status presentation and emits its update directly to ActionsBar — a second published badge would duplicate both label and meaning.
      • Editor?lang= is read on load (primary language ⇒ source tab, which the composable stores as ''), plus an mdi:open-in-new link to the live page of whichever language is on screen. It is passed as useArticleTranslations(id, initialLang) rather than assigned to activeLang afterwards, and the reconciling watchEffect is gated on status === 'success'. Both matter: an empty targetLanguages means either "this site has no targets" or "the request has not resolved", and reconciling against the second silently reset ?lang=en to the source tab before the payload arrived — the deep link looked like it did nothing.
    • SEO framing that drove the above: translated pages are not duplicate content — that is what hreflang is for, and the untranslated-locale fallback already canonicalises onto the primary URL. The real Google exposure is scaled content abuse (bulk machine translation published without review), which makes HYBRID the safe mode and AUTO the risky one. The review step is therefore load-bearing, not polish, and anything that makes approving expensive pushes tenants onto AUTO.

6a. Plan Matrix (ClientSite.plan — enum ClientPlan)

Marketing names diverge from the DB enum: marketing FREE = enum BASIC. Enum is the source of truth (prisma/schema.zmodel:84); the pricing copy now lives in the separate ../landing project.

A literal @ in any i18n message breaks the production build. vue-i18n reads @ as the start of a linked message (@:key), so an e-mail address in a message fails with Invalid linked format. It only surfaces under jit: true, which the build uses and neither typecheck nor vitest does — write &#64; instead (these messages render through v-html, so the entity decodes in both the link text and the mailto: href).

Legal texts (i18n/locales/{cs,en}/legal.json, rendered by pages/privacy.vue / pages/tos.vue) are a duplicate of the same file in ../landing — edit there and copy across, or the two sites drift. The two copies differ only in line endings (app LF, landing CRLF), so compare with diff --strip-trailing-cr. The [[BRACKETS]] placeholders are filled (as of 10 Aug 2026); the ARES link is built from the IČO, so both change together. The postal address stays out on purpose — ARES carries it. The processor list names no hosting provider on purpose — app, DB and cache run on own hardware, so there is no hosting processor to disclose under Art. 13(1)(e), and the "transfers outside the EEA" paragraph therefore states no baseline region. The LinkedIn section is written to match the retention limits in the LinkedIn API Terms (profile ≤24 h, social activity ≤48 h) — changing what server/utils/linkedin/ stores means changing that section too.

Who may write which ClientSite field. shared/utils/clientSiteFields.ts is the single source: PRIVILEGED_CLIENT_SITE_FIELDS (plan, tokenLimit) are superadmin-only; everything in TENANT_EDITABLE_CLIENT_SITE_FIELDS, including a tenant's own gamNetworkCode, is fair game for a site's own admin. server/api/clients/[id]/index.patch.ts builds a zod pick from the applicable list, so a tenant's privileged extra keys are silently stripped rather than rejected. The partition is pinned by tests/unit/clientSiteFields.test.ts (the field lists carry no zod/ZenStack import precisely so the test does not depend on generated shared/zod). Reactivating a soft-deleted site (deletedAt: null) is superadmin-only too; self-deactivation stays open. Side effects must read the parsed value, never raw body — requestedTokenLimit, not scalarBody.tokenLimit.

Who may read which ClientSite field. Same file, read side: PUBLIC_CLIENT_SITE_FIELDSpublicClientSiteSelect (a fieldMask, i.e. a Prisma select) is the only projection any session-less route may return, and CLIENT_SITE_SECRET_FIELDS names what must never join it (apiKey, the three stripe* ids, token quota + usage, billing amounts, communityInsight, AI prompt config, rate assumptions). Being a whitelist, a newly added zmodel field is private until someone lists it; tests/unit/clientSiteFields.test.ts walks Prisma.dmmf to assert both lists only name real scalars, that they are disjoint, and that the generated select is exactly the public list. tests/server/clients/publicProjection.test.ts pins the routes themselves.

  • clients/slug/[slug].ts (public, hit by useClientSite() on every page → lands in the SSR payload) and clients/[id]/by-userid.get.ts (public, powers the author card) select through the whitelist. Both previously returned the whole row from raw prisma, i.e. every tenant's apiKey and Stripe ids were readable by curl-ing any blog. @omit would not have helped: these go through raw prisma, and ClientSite is @@allow('read', true) anyway, so the select is the control.
  • clients/index.get.ts is role: 'superadmin' via requireDb. It was session-optional with getEnhancedPrisma(user), and because the model's read policy is true that enhanced client happily dumped every tenant row plus each site's users' usernames and e-mails to anonymous callers — a reminder that enhanced Prisma only enforces what the policy says, and @@allow('read', true) says "everyone".
  • clients/[id]/index.get.ts (settings source, keeps apiKey/Stripe ids for the owner) now 403s unless id === user.clientSiteId or the caller is superadmin. The old check was ['superadmin','admin'].includes(role) with an attacker-controlled id — any tenant admin could read any other tenant's full row.
  • clients/status.get.ts is where owner-only numbers live now (plan, tokenLimit/tokenRemaining/totalUsage, createdAt, firstPaidAt, focus, audience, plus a derived hasActiveSubscription boolean — the Stripe subscription id is selected but stripped before the response so the upgrade CTA can be gated without leaking it). The site id comes from the session (user.clientSiteId), never a route param, so it has no IDOR surface at all; admin min-role, and it returns null for a superadmin with no own site. Exposed as useClientSiteStatus() next to useClientSite() and consumed by pages/admin/index.vue (trial modal), Client/Version.vue (quota bar) and Article/Empty/Owner.vue (focus/audience for the launch checklist) — all of which used to read those fields off the public payload.
  • useClientSiteStatus() fetches through useRequestFetch() and returns the useAsyncData handle, not data.value. Both halves were bugs: bare $fetch during SSR sends no session cookie, so the endpoint 401'd, a .catch(() => null) swallowed it, and the null was baked into the payload the client never re-fetched — every consumer silently ran on fallback numbers for the whole session (fabricated quota, dead trial modal, dead AI gate). Returning data.value then froze the snapshot at setup time, so nothing re-rendered after a top-up. The same useRequestFetch() rule as the Colada queries above — it applies to any authenticated useAsyncData handler. Consumers must not reintroduce fallback constants (?? 20000): when the limit is unknown the quota UI hides rather than inventing a denominator, otherwise 0/20000 reads as 0 % and fires the low-token alarm on a full account.
Capability FREE (BASIC) PRO PREMIUM CUSTOM
Price (per month, USD) $0 $49 $99 On request (sales)
Stripe checkout STRIPE_PRICE_PRO STRIPE_PRICE_PREMIUM Sales-led, no SKU
Revenue share Halted Halted Halted Halted
Manual article writing
Subdomain ✅ (free) ✅ + apex domain
Custom (apex) domain
White-label (no Topiqu branding)
AI article generation ✅ (token bundle) ✅ (token bundle) ✅ (unlimited)
AI sentiment + auto images
Advanced SEO optimization
Article import
Priority indexing + sourcing
Custom emojis & branding
Custom ad banners Halted Halted Halted Halted
Analytics Basic Basic + GA4 Basic + GA4 Basic + GA4
Support Community Standard Priority 24/7 Dedicated

Feature gates are checked via ClientSite.plan plus the ClientFeature rows (see §6b) — and, for non-AI capabilities, the remaining booleans on the model (allowGtag, allowShapes). The BillingPlans enum (MONTHLY / ANNUAL) is orthogonal to the plan tier.

6c. Trial is a real plan, firstPaidAt is the paid marker

  • checkout.post.ts creates every tenant on TRIAL_PLAN (= PREMIUM) with firstPaidAt: null, so the trial exercises the whole product including the crons. The plan column therefore cannot tell you whether anyone paidfirstPaidAt does, and every predicate in shared/utils/trial.ts (isInTrial, trialExpired, needsTrialDowngrade, pure + tests/unit/trial.test.ts) reads it. Before this the trial was a client-side plan === 'BASIC' && age < 14d check in pages/admin/index.vue, which meant AI_CAPABLE_PLANS locked the launch checklist's voice step for exactly the people being courted.
  • stripeWebhook.ts → marksFirstPayment: a trialing checkout moves plan but must not stamp firstPaidAt, or the trial ends the day it starts. Both checkout.session.completed and the subscription.updated trial-end path call syncPlanFeatures on whatever plan they resolve.
  • trial-expiry cron (0 5 * * *) only touches card-less trials. expiredTrialWhere requires stripeSubscriptionId: null because Stripe owns a card-backed trial's lifecycle — promoting on conversion, revoking through revokesPlan on a failed first invoice — and a cron downgrade would race that webhook mid-conversion. tests/server/tasks/trialExpiry.test.ts pins the SQL filter against the predicate; drift either strands tenants on the trial plan forever or wastes the query.
  • syncPlanFeatures at signup is deliberately non-fatal (try/catch, TRIAL_FEATURE_PROVISIONING_FAILED). It throws on an unseeded Feature catalog — the hazard already documented in §6b — and signup is the one path that must not depend on it; the plan column alone still unlocks the UI.
  • AI generation is gated on the plan, not on ClientFeature (hasAiPlan, shared/utils/plans.ts, moved there from emptySite.ts): generate/index.post.ts reads it off the row it already fetches, generate/enhance.post.ts via requireAiPlan. Feature rows would lock out tenants provisioned before §6b's backfill. Neither endpoint had any plan check before, so the token balance was the only brake and an expired trial kept generating on its leftover grant — which is also why the downgrade leaves tokenRemaining alone (a trial tenant may have bought a token pack).
  • Known wart: end-trial.post.ts writes firstPaidAt as the trial-modal dismissal marker, so a tenant that chose "continue free" is indistinguishable from one that paid. Pre-existing; a dedicated nullable column is the fix.

6b. ClientFeature is the only source of truth for AI / Sentiment / Crons

Gating used to be split three ways and the halves disagreed, which is how a PREMIUM tenant ended up staring at a settings panel that showed AI, translations and scheduled generation all switched off — with a monthly price next to each — while the app cheerfully generated and translated in the background:

  1. ClientFeature rows → what the settings panel rendered (activeFeatures), written only by features.patch.ts; nothing created them on a plan grant, so a paying tenant had none.
  2. ClientSite.enableAi/enableCron/enableSentiment → what the translation endpoint and cron actually checked, written only at onboarding and never by the toggle.
  3. Bare plan checks → sentiment-analysis.ts; and generate-article.ts checked nothing at all beyond generationFrequency + tokens, so a tenant who switched AI off still got articles.

Now: ClientFeature decides, everywhere. The booleans are dropped (migration 20260806120000_plan_features_single_source, which also backfills rows for existing PRO/PREMIUM sites). That backfill is an INSERT … FROM "ClientSite" JOIN "Feature", so it silently inserts nothing when the Feature catalog is empty — and the catalog is written only by prisma/seed.ts, a dev seed that also mints test users with a hardcoded password and therefore never runs in production. A prod DB can consequently end up with the gating live and no ClientFeature row anywhere, which takes generate-article, translate-pending and sentiment-analysis dark at once while everything else keeps working. Symptom to recognise: crons stop touching tenant rows with no error in the log, because the batch query simply matches nobody.

  • planFeatures.ts → planFeatureSync(plan, active) is the pure decision (activate what the plan covers, revoke what a downgrade dropped, skip CUSTOM — that is the à-la-carte plan where operators pick by hand and billingLockedUntil carries real billing weight). syncPlanFeatures(tx, siteId, plan) applies it and is called on every plan transition: all stripe/webhook.ts paths (promote, portal plan change, revocation → BASIC), the superadmin plan edit in clients/[id]/index.patch.ts, and the dev switch _dev/plan.patch.ts — each inside the same transaction as the plan write.
  • syncPlanFeatures is "make this site consistent with its plan", not just "write feature rows." After provisioning/revoking it re-asserts the two invariants that outlive a feature: syncAutoRelease (see below) and recalcFeatureBilling. Anything that changes a plan therefore gets all three effects for free, which is why the dev endpoint was wired in — a locally switched plan used to leave features and billing behind and stop reproducing production.
  • Reads go through activeFeatureFilter(code) (a relation where for cron batch queries) or hasActiveFeature(db, siteId, code). Both take a structural DB type rather than Prisma.TransactionClient, matching translationQueue.ts/translationSlug.ts — the concrete Prisma type does not accept the enhanced client. The read path takes the narrow FeatureReadDb (clientFeature.count only); only the write path needs the wider FeatureSyncDb (adds feature + clientSite).
  • Backfilled/plan-granted rows get billingLockedUntil = now() (no lock): billableMonthlyTotal bills nothing outside CUSTOM, so the 30-day anti-gaming window is meaningless for an included feature. That is exactly why billability cannot key off the lock window alone — see billableFeatureWhere below.
  • autoRelease cannot outlive ARTICLE_CRONS (syncAutoRelease). The switch is hidden without scheduled generation (Form/Client/AI.vue) and costs a danger confirm to turn on, so leaving it true through a revocation meant a later re-subscribe silently resumed auto-publishing generated articles. Enforced on both the plan path (syncPlanFeatures) and the manual toggle path (features.patch.ts, after the AI cascade).
  • Enqueuing translations is itself gated on the AI feature (syncArticleTranslationQueue). translate-pending already refused to drain the queue without it, so a revoked tenant still on translationMode: AUTO kept piling up invisible PENDING rows on every publish — which a later re-subscribe would flush as one burst of months-old translations. features is optional in the structural QueueDb (a concrete Prisma row type has no such key and would not satisfy it otherwise); the select always asks for it and a missing value fails closed.
  • The Feature catalog is load-bearing and was seeded by hand. With gating derived from it, an empty catalog would silently switch every feature off for everyone, so syncPlanFeatures throws on a missing code instead of skipping it, and prisma/seed.ts upserts the three rows (update: {} — it never overwrites real prices).
  • Consequence to know: sentiment-analysis.ts used to run for plan IN (PRO, PREMIUM), but getAllowedFeatures only grants SENTIMENT on PREMIUM/CUSTOM. Gating on the feature makes the matrix authoritative and stops sentiment for PRO tenants — that drift was in the cron, not the matrix.
  • Panel side: Form/Client/AI.vue quoted a per-feature monthly price on every plan even though billableMonthlyTotal returns 0 off CUSTOM. Price now renders only for CUSTOM; everyone else gets an "included in your plan" badge (common.features.includedInPlan). The three near-identical 45-line toggle blocks collapsed into Form/Client/FeatureToggle.vue (accent classes passed as whole static strings so UnoCSS still sees them).

Feature toggles & dynamic pricing (AI / SENTIMENT / ARTICLE_CRONS)

  • Single source of plan→feature logic: server/utils/planFeatures.ts (pure, unit-tested) — getAllowedFeatures(plan) (AI+Crons on PRO+, Sentiment on PREMIUM+), FEATURE_DEPENDENCIES (Sentiment & Crons require AI), getMissingDependencies, getDependents, isCustomPlan (the à-la-carte plan — features billed individually), billableMonthlyTotal. Consumed by both clients/[id]/index.get.ts (allowedFeatures) and clients/[id]/features.patch.ts so the two never drift.
  • Two billing modes, both applied by the shared recalcFeatureBilling(tx, siteId, plan, billingPlan): standard plans (PRO/PREMIUM) → features are included, toggling is a free capability switch, monthlyPayment stays 0 (the plan itself is the Stripe charge). CUSTOM → à-la-carte: monthlyPayment = Σ billed feature priceMonthly``(USD);PERMANENTbillingPlan is comped to 0.`annualFromMonthly`carries the −20 % annual discount. Called from`features.patch.ts` and every plan transition, so the stored price can no longer drift from the plan.
  • What counts as billable is billableFeatureWhere = isActive: true OR billingLockedUntil > now, not the lock window alone. The window alone under-billed twice: a feature switched on past its 30 days stopped counting, and — the sharp one — a tenant moved from PREMIUM onto CUSTOM inherited all three features with billingLockedUntil already in the past, carrying them à la carte for free until someone happened to toggle something.
  • Server-enforced invariants (not just client gating, closing an IDOR + a broken-access-control gap): admin may only toggle their own clientSiteId (superadmin any); enabling a feature requires getAllowedFeatures[code] (else 403) and its AI prerequisite active (else 400); disabling AI cascadesgetDependents('AI') are atomically disabled in the same transaction. Client mirrors this with a danger confirm modal (Form/Client/AI.vue → showAiDisableModal) before disabling AI while dependents are on.
  • Rate card is USD. Feature.priceMonthly holds USD; the whole display layer is USD-base: POST /api/currencyserver/utils/currency.ts → usdCrossRate (CNB daily rates, cross-converted via CZK, amount-normalized, unit-tested), useCurrencyRate returns USD→target (1 for USD), and Billing.vue/AI.vue multiply USD amounts by the rate (was CZK-base divide). CZK is now just another display currency.

Stripe wiring

  • Client: all handlers (subscribe, checkout, portal, webhook, onboarding/checkout) share one lazy singleton — server/utils/stripe.ts → useStripe() — which reads STRIPE_SK once and pins apiVersion: '2025-08-27.basil' (SDK 18.5.0) so a Stripe-side default bump can't shift behavior. Lazy (not eager like prisma) so onboarding/checkout keeps its graceful "no key → still return dashboard URL" fallback instead of throwing at import.
  • Current commercial pricing is PRO $49/mo and PREMIUM $99/mo (USD). The recorded sandbox catalog still contains the older Pro $39/mo (price_1TsL6URaW639ixKzBpb6SqlR, prod_Us44PFOun811GC) and Premium $79/mo (price_1TsL6VRaW639ixKzUTRL2PRg, prod_Us44yt7w1ngzti) recurring prices; those IDs are historical test fixtures, not the current price source. STRIPE_PRICE_PRO / STRIPE_PRICE_PREMIUM must point at prices matching $49/$99 in the deployed Stripe mode. Earlier CZK attempts and the original 490/990 Kč prices are archived on the same sandbox products.
  • Historical annual sandbox prices (−20 %, based on the old $39/$79 monthly prices): Pro = price_1TsLMoRaW639ixKzZBlbfJWZ ($374.40/yr), Premium = price_1TsLMpRaW639ixKzQlOpJZQB ($758.40/yr). They do not represent current $49/$99 pricing. Do not wire them into production; current annual pricing must be decided and matching Stripe prices created before annual checkout is offered.
  • Subscription checkout: POST /api/stripe/subscribe (mode subscription). Body plan + optional interval (month/year); when interval is absent it falls back to the site's billingPlan === 'ANNUAL' ? 'year' : 'month'. interval is stamped into checkout + subscription metadata.
  • Token top-ups: POST /api/stripe/checkout (mode payment, ad-hoc price_data). Token packs are a server-side catalog (shared/utils/tokenPacks.ts, getTokenPack) — the client sends only a pack id; price + token amount are resolved server-side (closes price-tampering).
    • tokenLimit is the site's total allocated token capacity and tokenRemaining is its spendable part; the invariant is 0 ≤ tokenRemaining ≤ tokenLimit. A top-up increments both values by the purchased amount, while totalUsage records actual consumption exclusively through consumeClientTokens. Consumption uses an optimistic compare-and-swap: exact provider usage is recorded after the call, the available balance is debited only to zero, and concurrent completions cannot persist a negative value. Migration 20260831113000_token_capacity_invariant repairs historical data and installs checks; follow-up 20260831123000_token_capacity_rolling_deploy adds a before-write normalizer so legacy instances cannot violate those checks during a rolling deployment.
  • Customer Portal: POST /api/stripe/portal (billingPortal.sessions.create) — self-serve plan change (with proration), cancel, payment method, and invoice history + PDF. Surfaced from the settings billing tab. Requires the Customer Portal to be enabled/configured in the Stripe Dashboard (test + live).
  • Auth: checkout, subscribe, and portal all require a session and derive clientSiteId from it (superadmin may override via body) — never trust clientSiteId from the body (was an IDOR).
  • Webhook (POST /api/stripe/webhook) handles checkout.session.completed for both modes, customer.subscription.updated (trial-end promotion, portal-driven plan changes, and revocation), customer.subscription.deleted (→ downgrade to BASIC), and invoice.payment_succeeded (→ bump lastPaidAt / lastInvoicedAt). Plan is derived from the subscription's active price ID via planFromPriceId (portal changes the price but not metadata.plan), falling back to metadata. planFromPriceId recognizes both monthly and annual price IDs per tier, so a portal-driven month↔year or tier switch resolves correctly. Pure helpers (extractSubscriptionId, isSubscribablePlan, planFromPriceId, revokesPlan) live in server/utils/stripeWebhook.ts (unit-tested); extractSubscriptionId reads invoice.parent.subscription_details.subscription (Stripe API 2025-03-31.basil removed top-level invoice.subscription).
  • Losing a plan is not only subscription.deleted. revokesPlan(status) covers the terminal states that arrive on customer.subscription.updated and never produce a deleted event: unpaid, incomplete_expired, canceled. Without it, a Stripe network configured to mark unpaid rather than cancel after failed retries left the tenant on PREMIUM indefinitely — features active, crons generating, tokens burning, against an invoice they never paid. past_due is deliberately not revoking: that is the dunning grace period where Stripe is still retrying.
  • Both revocation paths share revokeToBasic(siteId, { clearSubscription }) (one transaction: plan: 'BASIC' + syncPlanFeatures). clearSubscription only on a terminal deletion — an unpaid subscription still exists in Stripe and revives on payment (updated → active re-promotes it), so its id is kept. stripeCustomerId survives either way, or the tenant loses portal access to their own invoice history.
  • Billing UI: the settings billing tab (Form/Client/Billing.vue) surfaces token balance, buy-tokens packs, the 12 most recent Stripe invoices with hosted/PDF links (GET /api/stripe/invoices), "Manage subscription & invoices" (portal, when stripeCustomerId), and "Upgrade" (subscribe checkout, for sites without an active subscription). Success/cancel/return URLs all land on /settings?tab=billing.
  • Third upgrade surface: Admin/UpgradeBanner.vue on the admin dashboard, the only one that checks out inline (POST /api/stripe/subscribe). It reads the same getUpgradeTarget(plan, hasActiveSubscription), which is what keeps it off active subscribers — mode: 'subscription' opens a second subscription rather than changing the current one, so those upgrades have to go through the portal. Its v-if used to be a page-level plan === 'BASIC', which hid the PREMIUM pitch entirely while standing in for that guard by accident. Dismissals are a upsell-dismissed localStorage list keyed per offered plan (dismissing PRO still leaves PREMIUM to be offered later), which is why the banner is <ClientOnly> — the server cannot know whether it renders.
  • Second top-up surface: the admin quota pill Client/Version.vue (bottom-right, layouts/default.vue, isAdmin only). Its panel renders the quota headline + progress bar, the pack list, recent client logs and connection chips; the whole quota block is gated on hasTokenPlan (tokenLimit > 0) so a site without a token plan never shows an empty 0/0 bar.
    • Pack view models come from app/utils/tokenPackPresentation.ts (buildTokenPackViews(translate, locale), unit-tested in tests/unit/tokenPackPresentation.test.ts) — a pure mapper over TOKEN_PACK_LIST. Prices/token amounts are never re-declared client-side: money is formatted by formatTokenPackPrice from the shared catalog, and only PACK_PRESENTATION (icon / i18n key / featured) is app-layer. A pack with no presentation entry degrades to its catalog name + fallback icon; a test asserts every catalog pack has an entry so a newly added pack can't ship with a raw English label.
    • The upgrade CTA uses the shared getUpgradeTarget(plan, hasActiveSubscription) (shared/utils/plans.ts) and links to /settings?tab=billing rather than checking out inline.
    • No UnoCSS reset is loaded anywhere (nuxt.config.ts → css: ['~/assets/styles/base.scss'], whose only global reset is * { margin/padding/box-sizing }; @unocss/reset is a transitive dep, never imported). A bare <button> therefore keeps the UA chrome — border: 2px outset ButtonBorder, background: ButtonFace, color: ButtonText — which is why the plan × tokens trigger read as a crude pill until it was given appearance-none border-0 bg-transparent p-0 text-inherit. base.scss then adds button:hover (light) and button + button:hover (dark) backgrounds at specificity (0,1,1)/(0,1,2), so a plain bg-transparent (0,1,0) loses in dark mode — cancelling them needs the dark:-prefixed duplicates. Any new unstyled button in this codebase faces the same two layers.
    • Pack rows are plain <button>s, not Button/index.vue — that component forces h-12, justify-center, its own border/shadow, a hover:scale-105, and a variantClass background appended after any custom bg-*, so custom gradients/variants fight it non-deterministically in UnoCSS. Badges are laid out in flow (never absolute with negative offsets inside an overflow-hidden parent, which previously clipped the "best value" ribbon and overlapped the price).
  • Onboarding auto-login. onboarding/checkout.post.ts mints a single-use onboardingLoginToken (32-byte random, @omit-ed, 30-min TTL — must outlive Stripe checkout dwell) on the new admin user and appends it to the post-onboarding redirect (/{lang}/autorizace?created=true&token=…[&session_id=…], both the no-plan and Stripe success_url paths). pages/autorizace/index.vue redeems it on mount via signIn('credentials', { loginToken }); the Credentials provider's authorize branches to authorizeWithOnboardingToken (server/api/auth/[...].ts) which validates token+expiry, atomically single-uses it (updateMany guarded on the token → count===0 blocks replay/races), then reuses the normal JWT/session pipeline. On expiry/failure the page toasts common.auth.onboardingLoginExpired and clears the query so the login form shows. Plan on the fresh session is BASIC until the Stripe webhook promotes it.
  • Required env: STRIPE_SK, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_PRO, STRIPE_PRICE_PREMIUM, STRIPE_PRICE_PRO_ANNUAL, STRIPE_PRICE_PREMIUM_ANNUAL.

Ad revenue share — HALTED (GAMAdEarning ledger, retained implementation)

Product status (2026-08-09): GAM and content monetization are halted until further notice. Do not market revenue share or ad banners, enable them for tenants, or treat the historical plan ratios below as current commercial entitlements. The implementation and schema remain in the repository for a possible restart; production should leave GAM credentials unset so gam-sync no-ops. Restarting monetization requires an explicit product decision and a fresh review of pricing, ratios, consent, reporting and payout operations.

  • Retained historical model (inactive): ad revenue was split creator/platform per plan (BASIC 0/100, PRO 70/30, PREMIUM 90/10, CUSTOM 100/0). Ads serve under one platform GAM account; each impression carries the site's client_id — set as page-level GPT targeting in app/composables/useGam.ts → initialize — so GAM revenue reports break down per site. useAdChance is now only an SSR-safe useState mirror of that targeting (the former client-side Math.random() owner lottery is gone); nothing reads it yet, so it is a candidate for deletion unless a consumer appears.
  • Split is deterministic + server-side (server/utils/adRevenue.ts, pure + unit-tested in tests/unit/adRevenue.test.ts): splitAdRevenue/buildEarningRow apply the plan ratio to GAM gross; the invariant clientCents + platformCents == grossCents holds (incl. negative clawbacks). CLIENT_SHARE is the single source of the ratio.
  • Ledger: AdEarning (prisma/models/ads.zmodel, migration 20260725120000_ad_earnings) — one row per (clientSiteId, periodStart, periodEnd) holding gross/client/platform cents, a shareRatio snapshot, and AdPayoutStatus. Read = superadmin or admin of own site; create/update/delete = superadmin/system only (clients cannot fabricate earnings). shareRatio is snapshotted per row so a later plan change never retroactively rewrites past earnings.
  • Ingest: server/tasks/gam-sync.ts (cron 0 4 * * *) pulls the previous UTC day per-client and idempotently upserts the ledger (re-runs correct the numbers). No-ops when GAM_NETWORK_CODE/GAM_SERVICE_ACCOUNT_KEY are unset; fails loudly on half-configuration so revenue is never silently dropped.
  • GAM Reporting client (server/utils/ads/, no new dependency): gamAuth.ts mints an RS256 service-account JWT with node:crypto and exchanges it for an admanager access token (cached until ~2 min before expiry); gamReport.ts drives the Ad Manager REST v1 report lifecycle — create report → :run → poll the operation → paginate :fetchRows. Attribution comes from the client_id key-value dimension named by GAM_CLIENT_DIMENSION (network-specific, e.g. CUSTOM_DIMENSION_<KEY_ID>_VALUE); missing it throws rather than attributing revenue to nobody. Micros are summed per site before the single microsToCents rounding, so split rows can't drift a cent. Pure helpers (toGamDate, gamDateRange — turns our exclusive periodEnd into GAM's inclusive endDatebuildReportDefinition, readValue, parseReportRows) are unit-tested in tests/unit/gamReport.test.ts; the network path is untested-by-design (no live credentials).
  • Read API: GET /api/clients/earnings (requireDb, minRole: 'admin') — optional from/to (default last 12 months, hard cap 24) and clientSiteId. Tenant isolation is ZenStack policy, not the where clause: an admin passing a foreign clientSiteId simply gets nothing back. Returns summaries (one per currency — summarizeEarnings never sums across currencies), each with totals, byMonth, and clientCentsByPayoutStatus, plus a bySite breakdown and a scope flag (platform for an unfiltered superadmin).
  • Consent and advertising: ConsentManager.vue is a conventional category-based consent center: Technical cookies are always active; visitors independently choose Analytics and, when the tenant has advertising, Marketing. The versioned choice lives in the host-only topiqu_consent cookie for 180 days. A service snapshot makes a newly enabled GA4 or advertising integration prompt again. Rejecting Analytics prevents first-party view tracking and GA4; revocation disables GA4 and clears visible GA cookies. Rejecting Marketing prevents both the platform AdSense script and tenant GAM/GPT from loading. Settings are always available in the footer. The bottom-right launcher is role-aware through the pure consentLauncherFor: admin and superadmin get a dedicated cookie action directly in the collapsed ClientVersion bar; readers and anonymous visitors get ConsentSettingsButton in the same bottom-right area after making a choice; non-public reader surfaces get neither. Both launchers expose the shared bottom-action-bar marker, which lifts the back-to-top control above them only while one is present. Platform AdSense is tenant-mandatory only on BASIC (platformAdsEnabledForPlan) and absent by default on PRO, PREMIUM, and CUSTOM; this plan policy determines availability, while the visitor's Marketing choice determines loading. Every paid tenant may optionally enter its own GAM network code (Settings → Integrations → Google Ad Manager), which makes the Marketing category and its explicit placement available independently of plan. The tenant must create /article/sidebar and configure its GAM privacy message for its domains. ArticleTOC.vue owns the desktop right rail and exposes its sidebar slot below the TOC; the article page fills it with a 160×600 unit and maps sub-1024px viewports to no size. Empty slots collapse. GA4 stays manual with cookie_domain: 'none'. Browser Sentry keeps error reporting only; performance tracing and Session Replay are disabled. The banner root is a <div role="dialog">, never an <aside>: aside implies complementary, which does not allow dialog, and the invalid pair leaves a malformed node in the accessibility tree — the tree Lighthouse’s agentic-browsing category scores and AI agents navigate by.
  • Pending (fáze 2): payout rail (Stripe Connect / Wise) + EU DAC7 reporting — payoutStatus/payoutRef are the anchors already in place; admin earnings UI (API exists, no page yet).
  • Token top-ups need no Stripe catalog objectsstripe/checkout builds ad-hoc price_data from shared/utils/tokenPacks.ts (10k/$2.99, 25k/$4.99, 50k/$9.99) at checkout, so packs are ready without pre-created Prices.

7. Tooling

  • ESLint 9 (@nuxt/eslint + eslint-plugin-perfectionist), Prettier 3.
  • Testing: Vitest 4 with @nuxt/test-utils, jsdom, V8 coverage. Config in vitest.config.ts (jsdom env). Tests live in tests/**, co-located *.test.ts is also picked up. First suite: tests/server/stripe/webhook.test.ts.
  • Typecheck via vue-tsc (bun run typecheck); kept out of build for fast deploys, run separately in CI.
  • Package manager: bun (bun.lock), pinned to one version in package.json, .prototools, Docker and CI. tests/unit/releaseToolchain.test.ts prevents those declarations, the frozen install gate, the compiled E2E hook, or complete Prisma migration directories from drifting apart.
  • Scripts: dev, build, typecheck, test, test:watch, test:coverage, lint(:fix), prettier(:fix), fmt, zenstack:generate, prisma:deploy.

8. Notable Gaps / Observations

  • Test coverage is partial. Vitest suites exist under tests/server/** (stripe webhook, linkedin publisher + oauthState signing + token refresh, notifications poll, articlePolls, AI translation mask/rebuild + slug dedupe + queue) — server-side pure logic is covered; component/page coverage is still thin.
  • todo/ directory carries working notes inside the repo.
  • Serverless → self-hosted migration (done, 2026-08). The app runs on Dokploy/VPS; see §1 for the runtime and §4 for host topology. The phased playbook (MIGRATION.md) has been deleted now that it describes history. What it was still carrying, and what remains genuinely open, is:
    • scheduledTasks fire once per replica. Now live rather than hypothetical: all eight crons run natively, so scaling web past 1 replica in Dokploy — a one-click action — would duplicate article generation and double-count gam-sync revenue. Settle this before anyone scales: keep web at 1, move the tasks into a single-replica worker, or drop nitro.scheduledTasks for Dokploy Schedule Jobs hitting a CRON_SECRET-guarded endpoint.
    • No long-running worker yet. server/worker.ts and the queue abstraction never landed, so the queue epic in todo is unblocked but unstarted. Open question inside it: whether the queue belongs in Redis Streams at all, given ArticleJob/PdfJob are Prisma models and Postgres SELECT … FOR UPDATE SKIP LOCKED would keep jobs transactional with the rest of the data and out of a second stateful system.
    • Cache is not provisioned post-cutover — see §1 → Cache / Redis.
  • One uncommitted change at snapshot time: prisma/schema.prisma (regenerated artifact).
  • Mixed-locale routing (Czech slugs in pages/) — intentional, paired with @nuxtjs/i18n.
  • Article translations + real hreflang (implemented). Articles are translated per-language via the ArticleTranslation sidecar (see §6 → Article Translations). pages/clanky/[slug].vue resolves content locale-scoped (primary language → source Article; other locale → its PUBLISHED translation, falling back to the source as a legacy i18n alias when none exists). Once ≥1 translation is published, the page emits real hreflang + x-default and self-canonicalises per locale; with no translation it still collapses the duplicate /cs/en alias URLs onto the primary-language path (the original mono-lingual mitigation). hreflang is therefore only emitted for translations that actually exist, never speculatively.
  • One canonical origin for the whole appuseCanonicalOrigin() (app/composables/, wrapping the pure buildCanonicalOrigin/toAbsoluteUrl in shared/utils/seo.ts, tested by tests/unit/seo.test.ts). It derives the origin from the request host, which is mandatory here: every tenant lives on its own domain, so a hardcoded i18n.baseUrl / site.url would stamp the wrong host on every client site. It forces https: outside dev (a proxy that drops x-forwarded-proto would otherwise emit http: canonicals) and strips a leading www.. pages/clanky/[slug], stitky/[slug] and autor/[name] all build canonical/alternate/JSON-LD URLs from it — the last two previously used a raw ${protocol}//${host} and disagreed with the article page on www.
  • The i18n-generated hreflang links are absolutised in app.vue. useLocaleHead() prefixes its link entries with i18n.baseUrl, which is unset (and cannot be set statically — see above), so it emitted relative hrefs on i18n-alt-* / i18n-xd; search engines ignore relative hreflang, which is what a Bing audit of pixbo.topiqu.com flagged. app.vue now maps i18nHead.link through toAbsoluteUrl(href, canonicalOrigin) before spreading it into useHead, so every generated alternate is fully qualified per tenant. Already-absolute hrefs pass through untouched, so this stays correct if baseUrl is ever configured.

8a. AEO / GEO Surface

Everything a search or answer engine reads. All of it is per-tenant, resolved from the request host via server/utils/tenant.ts → tenantByHost (cachedTenantByHost for hooks that fire on every request). Root hosts (topiqu.com, app.topiqu.com) resolve to null and serve none of it.

  • The article body reaches the crawler. Article/Parsed.vue used to split the stored HTML in onMounted behind an import.meta.client guard, so the served markup contained an empty container — and GPTBot, ClaudeBot, PerplexityBot and CCBot do not run JavaScript. The split now happens in articles/[id]/index.get.ts via server/utils/articleBlocks.ts (cheerio) and ships as blocks on the payload. Shaping rules live once in shared/utils/articleBlocks.ts; the editor preview adapts DOMParser onto the same builder (app/utils/articleBlocks.ts). Adjacent HTML nodes merge into one run — a wrapper per node breaks the adjacent-sibling rules prose depends on. Stored body images remain raw HTML, but Article/Parsed.vue now injects Nuxt Image/IPX WebP candidates through shared/utils/articleImages.ts. Every image retains its direct upload in data-original-src; a failed proxy response removes srcset and retries that upload, so responsive delivery cannot make the article unreadable.
  • Heading anchors come from headingSlug (NFKD, so Přehled trhuprehled-trhu, not p-ehled-trhu). stampHeadingIds writes them on save (POST/PATCH); the block builder stamps any body that predates that, and never overwrites an existing id. Article/TOC.vue only generates ids for the editor preview.
  • Structured data is one linked graph, not three hand-built ld+json blobs. app.vue defines only the Organization identity — nuxt-schema-org's i18n plugin already emits WebSite and WebPage with locale-aware @ids off the per-tenant site config, and redefining them detaches WebPage.isPartOf; useArticleSeo adds Article/BlogPosting, the author Person ({authorUrl}#author, the same @id autor/[name] defines) and any FAQ questions; useBreadcrumbItems({ overrides }) owns BreadcrumbList — passing overrides rather than remapping its result, because it emits the schema from its own items. Article.sources becomes citation, publishedAt becomes datePublished (createdAt is when the draft was opened), and totalWords/readingTime become wordCount/timeRequired. A second bare #organization node next to #identity is the module's own logo node, not a duplicate.
  • Sitemap is entirely server/api/__sitemap__/urls.get.ts; excludeAppSources and autoI18n: false turn off the module's own discovery, which only ever finds the app shell. It emits alternates only for PUBLISHED translations, and skips the homepage for a tenant with no articles and tag/author pages on BASIC — in both cases the page itself emits noindex, and a sitemap must not contradict it.
  • LLM surfaces: /llms.txt (tenant index, links each article as markdown), /md/{locale}/{segment}/{slug}[.md] (server/utils/articleMarkdown.ts, cheerio → markdown, no new dependency) and /rss.xml. The markdown variant is prefixed rather than a bare <url>.md: Nitro cannot bind a param owning part of a segment, and a top-level catch-all would swallow every page route.
  • shared/utils/routes.ts restates the i18n.pages segments because Nitro has no localePath. tests/unit/routes.test.ts asserts them against nuxt.config.ts — without it, renaming a route silently points every sitemap, feed and llms.txt URL at a 404.
  • Site identity is per-request. server/plugins/siteConfig.ts pushes the tenant's name/description/locale into the site-config stack above SiteConfigPriority.runtime. nuxt-seo-utils builds titleTemplate and ogSiteName from site.name, so without it every tenant's pages were titled … | Topiqu AI Blog and inherited the platform's Czech description. A tenant with no description pushes '', not nothing, so the platform line is suppressed rather than borrowed.
  • hreflang on articles is owned by the page. useLocaleHead derives alternates from the route alone, so it advertised a translation that may not exist; app.vue filters its alternate links out on the article route. x-default is alternates[0], which the API builds as the source language.
  • Extraction fields. Article.answer / keyTakeaways / faq (mirrored on ArticleTranslation) are what an engine quotes. They are opt-in per format/article — a padded FAQ on every post reads as generated and is the pattern engines demote. The editor and its preview render these fields with the same ArticleSummary / ArticleFaq components as the public article, because they live outside the TipTap HTML body. readFaq (shared/utils/articleFaq.ts) narrows the Json column; FAQPage is emitted only when it is non-empty. The mirror is only as good as the writegenerateTranslation translates and bills all three, but both write sites build their own data object, so a dropped field costs the tokens and then renders the source language under a translated body (tests/server/ai/translationRow.test.ts pins both). [id]/index.get.ts falls back to the source article per field, which is what makes the drop look like a feature gap instead of a bug.
  • Crawl budget: private paths are disallowed in robots.txt (the module re-emits each under every locale prefix, so they are listed bare), answer-engine crawlers get an explicit allow group, and CRAWL_LIMIT raises the global 70/10 s rate limiter on the read surfaces — a crawler working through a sitemap otherwise collects 429s and the pages behind them never get indexed.
  • Bot lists live in shared/utils/crawlers.ts, imported by both nuxt.config.ts and the runtime. Three fetching groups (ANSWER_ENGINE_BOTS retrieval/user-triggered, TRAINING_BOTS corpus, SEARCH_BOTS classic) plus AI_GROUNDING_TOKENS, which are not crawlers — Google-Extended and Applebot-Extended have no user agent at all and exist only as robots.txt control tokens for Gemini and Apple Intelligence, so they are absent from detectCrawler. Matching is longest-token-first or Claude-SearchBot resolves as ClaudeBot.
  • server/plugins/crawlerLog.ts ships one Better Stack line per crawler hit on a content route (source: 'crawler', bot, kind, host, path, status, rateLimited). Assets are excluded — they are the volume and say nothing about coverage. This is the only way to answer whether the AI crawlers arrive at all, whether they reach /llms.txt and the .md variants, and whether the rate limiter is turning them away.
  • bun run seo:check <origin> (scripts/seo-check.ts) fetches the whole surface as GPTBot and exits non-zero on failure: body present without JavaScript, sitemap lists articles, llms.txt/rss/markdown reachable, canonical self-referencing, tenant brand in the title, one h1, and the schema graph complete with no dangling @id. That last check is what caught WebPage.isPartOf pointing at a WebSite node that no longer existed.

9. Observability — Error Tracking & Session Replay

Why we adopted this

The app had zero runtime observability: when something broke for a real user (Nitro API, Tiptap editor, Stripe flow) we had no stack trace, no breadcrumb, no replay — only Vercel logs after the fact. As we move toward AWS SQS for async work, this gap gets worse: unlike an append-log bus (Kafka / Redpanda) you cannot rewind an SQS queue, so the only durable record of what happened to a failed job is whatever we capture at the moment it fails. We need stack traces + distributed traces emitted in-flight, plus front-end session replay to reconstruct user-facing bugs, that weren't captured by unit tests.

Why Sentry SDK + Better Stack (not Sentry SaaS)

  • No vendor lock-in. Better Stack's error tracking speaks the Sentry SDK protocol, so we instrument with the official @sentry/nuxt SDK and only point the DSN at Better Stack. Switching to (or back to) Sentry SaaS is a one-line env change — no code rewrite.
  • Cost at scale. Pricing diverges sharply at volume (SQS will generate many backend events); Better Stack is ~6× cheaper per unit ingest, while both are free at our current size.
  • Unified data layer. Better Stack co-locates error tracking, logs, uptime, and incident management — more value per integration than errors-only Sentry, given we had none of these.

Files / wiring

  • sentry.client.config.ts (project root) — SDK init from runtimeConfig.public.sentry. Empty DSN disables the SDK (local / CI).
  • sentry.server.config.ts — same init, but reads process.env and must never call useRuntimeConfig(). It is prepended as the first import of the server entry, so it executes before Nitro exists; the call throws there and the container dies on boot behind a 502.
  • The server config is not loaded on its own. The module's documented path is the Node flag --import ./server/sentry.server.config.mjs, but the container entrypoint is bun --bun server/index.mjs, so sentry.autoInjectServerSentry: 'top-level-import' imports it from the server entry instead. Without either, Sentry.init never runs server-side and only browser errors reach Better Stack. Never run both — that initializes the SDK twice.
  • nuxt.config.ts — registers @sentry/nuxt/module; runtimeConfig.public.sentry (DSN/environment); sentry.sourceMapsUploadOptions (build-time, skipped without SENTRY_AUTH_TOKEN); sourcemap.client: 'hidden'.
  • server/plugins/errorLogging.ts — Nitro error hook, 5xx only, shipping to Better Stack Logs via utils/logger.ts. Sentry covers error tracking; this is the second trail, alongside the cron: and audit: lines. Nitro answers a throw with its own 500 and reports it nowhere, so before this an unhandled handler error was invisible in both products.
  • Both channels only see errors nobody caught. Sentry auto-captures what escapes a handler; errorLogging.ts sees what Nitro turns into a 5xx. A try/catch that returns a fallback satisfies neither, so every deliberate swallow is invisible in production unless it reports itself — server/utils/reportError.ts → reportCaughtError (Sentry.captureException + logger.error) is that call. The explicit name avoids colliding with h3's one-argument reportError auto-import. It matters most where a failure is designed to be non-fatal: best-effort image generation, stock-provider fallbacks, and articles/generate — a streamed handler has already sent its 200, so it structurally cannot produce a 5xx and no amount of Nitro-level hooking will ever cover it.
  • .env.exampleNUXT_PUBLIC_SENTRY_DSN, SENTRY_URL/ORG/PROJECT/AUTH_TOKEN.
  • Replay masks all text + media (maskAllText, blockAllMedia) for GDPR.
  • CSP: works as-is — nuxt-security connect-src allows https:, replay worker uses existing blob: in script-src.

UX reconstruction foundations

  • app/app.config.ts is the central Nuxt UI visual configuration; app/assets/styles/main.css contains global semantic tokens, locally bundled variable fonts, reading typography, and reduced-motion behavior.
  • app/components/AppMedia.vue is the shared aspect-ratio-safe media primitive. Stable local and allowlisted CDN URLs render through NuxtImg as responsive WebP; untrusted or signed URLs stay direct. app/composables/useImageRetry.ts retries transient sources and falls back to an original upload when supplied. New AI article images pass through server/utils/images/optimize.ts before upload (WebP, max 1280 px, immutable cache), while IPX gives existing uploads responsive variants without a database migration.
  • Article interaction surfaces keep state visible at the control that owns it: comment like/dislike buttons carry their own pressed treatment, the terminal comment-page sentinel says there are no more comments instead of claiming there are none, and Article/Drafts.vue uses bounded two-column selectable cards rather than an overlay button that could escape or intercept the card layout.
  • useClientSite() hands back a plain nullable snapshot because ~16 call sites branch on its truthiness (if (clientSite), Boolean(clientSite && …)); a ref or reactive proxy would be truthy everywhere and break them silently. useLiveClientSite() returns the ref for the same useAsyncData entry, and both go through one fetchClientSite so they cannot drift. Header.vue needs the live form: it sits in the persistent layout and is never re-created by navigation, so a saved logo would otherwise wait for a full reload. Saving /settings calls refreshClientSite(); its getCachedData returns undefined for cause === 'refresh:manual', without which the payload would be served straight back and the refresh would be a no-op. Theme, typography, tagline and description reach the layout through the snapshot and still need a reload.
  • The brand-asset editor (Form/Client/LogoUploader.vue) is assembled from system primitives: UFileUpload for the empty state (it owns the dashed dropzone, drag-and-drop and the dragging state), USlider for zoom, UAlert for the failure. It previously hand-built the dropzone from a bare <UButton>, which defaults to solid primary — the whole 224 px zone rendered as a blue slab and its dashed border and hover states were invisible against it. useAvatarCropper exposes acceptFile so a file can arrive from a drop, not only from its own file dialog. Transparency behind a logo or favicon is drawn by .transparency-grid in main.css from --ui-bg-accented, so it follows the theme instead of a hardcoded gray per color scheme.
  • Templates may only render tags that resolve. Form/Client/LogoUploader.vue was written against a <Modal> and Form/Client/IntegrationsCatalog.vue against a <FormField>, neither of which exists — Vue renders an unresolved tag as an empty element, so the brand-asset editor and the analytics fields were mounted but invisible with no error beyond a console warning. Both now use the real components (UModal with #body, AppFormField), and nuxtUiContract.test.ts resolves every PascalCase template tag against app/components/, Nuxt UI and an explicit external allowlist.
  • A rejected upload always names its reason. File/Uploader.vue toasts a common.upload.* message with the measured value against the limit instead of a generic failure, and server/api/upload.ts returns the same keys translated, so a client-side refusal and a server-side one read alike. The favicon limits are one source of truth in shared/utils/favicon.ts. A file the browser cannot decode resolves the probe to null — the earlier Image.onload-only promise stayed pending forever, which is what made a bad favicon fail with no message at all.
  • Custom emoji management is optimistic in both directions: create inserts the local object-URL preview while the upload runs and swaps in the server record on success; delete removes immediately and restores the record at its previous position if the request fails. Related and author article queries project only the current viewer's reaction and collapse it to likedByUser, so every ArticleCard renders its initial heart state without exposing reactor identities. Author names are URI-encoded before their SSR data request, including generated AI names containing spaces.
  • Article/Collection.vue keeps pagination outside its card grid so it always spans the collection below the final row. Tenant settings read back the complete editable brand kit (tagline, favicon and typography included); omitting a field from clients/[id] makes a successful PATCH appear to revert as soon as the form refreshes.
  • The tagline is a standfirst, not a kicker: 80 characters is too long to sit beside the logo, so all three surfaces (pages/index.vue hero, Article/Empty/Visitor.vue, both Form/Client/Branding.vue previews) render it below the name at text-base text-highlighted, above the lighter text-lg text-muted description. In the hero it was a flex sibling of the logo, where min-width: auto stopped it shrinking and one long word escaped the column; it is now a block child of the column with max-w-[46ch] break-words. The tenant accent carries it as a left rule instead of text color, leaving accent to mean "interactive". brandTitle() puts it in og:title only — <title> stays the bare name because it is per-page and templated.
  • tests/unit/i18nCompleteness.test.ts keeps Czech and English keys synchronized and rejects unresolved static translation calls.
  • playwright.config.ts and tests/e2e/ provide deterministic responsive visual and accessibility checks. They require a separate TEST_DATABASE_URL and refuse to reuse DATABASE_URL. The release-smoke project is deliberately separate from the dev-server matrix: CI first installs with the Docker-pinned Bun version and --frozen-lockfile, builds the real Nitro .output, then opens a seeded public article with service workers enabled. It verifies same-release JS/CSS availability, rejects SRI on mutable Nuxt chunks, reloads under the active worker, and pins /sw.js to a non-cached, non-app-shell navigation configuration.